mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Add logs screen
This commit is contained in:
@@ -5,7 +5,7 @@ description: Adapts Kotlin/Compose code (diff, single file, or multiple files) t
|
||||
|
||||
# Adapt to style
|
||||
|
||||
Refactor target code to match [`CODE_STYLE.md`](../../../CODE_STYLE.md) at the repository root. **Do not change behavior.**
|
||||
Refactor target code to match `[CODE_STYLE.md](../../../CODE_STYLE.md)` at the repository root. **Do not change behavior.**
|
||||
|
||||
## Writing new code
|
||||
|
||||
@@ -15,6 +15,8 @@ When implementing features (not a style-only pass):
|
||||
2. Follow it from the start — inline single-use bindings, idiomatic Kotlin, match neighboring files.
|
||||
3. **Do not ask the user style questions** — apply the guide and use your judgment.
|
||||
|
||||
If the user used this skill in a prompt asking to implement/fix something, you should just adhere to the coding style.
|
||||
|
||||
## Style adaptation pass
|
||||
|
||||
When cleaning up an existing diff or file set:
|
||||
@@ -46,13 +48,11 @@ When unsure how to refactor something:
|
||||
|
||||
1. Create or append to `.cursor/code_style_progress_<YYYYMMDD-HHmm>.md` (use current local time).
|
||||
2. For each item:
|
||||
|
||||
```markdown
|
||||
```markdown
|
||||
## relative/path/File.kt
|
||||
- Unsure: [specific construct and why]
|
||||
- Chosen approach: [what you did for now]
|
||||
```
|
||||
|
||||
```
|
||||
3. Continue refactoring — do not block on open questions.
|
||||
4. After all files are done, **re-read** the progress file and ask the user the listed questions.
|
||||
|
||||
@@ -82,3 +82,4 @@ Summarize:
|
||||
- Files touched and main style changes.
|
||||
- Any entries from the progress file that need user decisions.
|
||||
- Build result.
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -200,10 +199,10 @@ class MainActivity : ComponentActivity() {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf("messageIds" to messageIds))
|
||||
}
|
||||
Log.d("MainActivity", "Marked ${messageIds.size} messages as read: $messageIds")
|
||||
Logger.i("MainActivity", "Marked ${messageIds.size} messages as read")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Failed to mark messages as read", e)
|
||||
Logger.e("MainActivity", "Failed to mark messages as read", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.fromchat.fcm
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
@@ -8,6 +7,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||
@@ -15,8 +15,11 @@ import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
Log.i("FromChatFCM", "onMessageReceived: from=${remoteMessage.from} dataSize=${remoteMessage.data.size}")
|
||||
Log.d("FromChatFCM", "onMessageReceived data=${remoteMessage.data}")
|
||||
Logger.i(
|
||||
"FromChatFCM",
|
||||
"onMessageReceived: from=${remoteMessage.from} dataSize=${remoteMessage.data.size}",
|
||||
)
|
||||
Logger.d("FromChatFCM", "onMessageReceived data=${remoteMessage.data}")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -30,13 +33,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val messageType = pushData["type"] ?: "public_message"
|
||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||
if (ApiClient.token.isNullOrBlank()) {
|
||||
Log.w("FromChatFCM", "No auth token in memory; loading persisted data before handling push")
|
||||
Logger.w("FromChatFCM", "No auth token in memory; loading persisted data before handling push")
|
||||
ApiClient.loadPersistedData()
|
||||
Log.d("FromChatFCM", "Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}")
|
||||
Logger.d(
|
||||
"FromChatFCM",
|
||||
"Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}",
|
||||
)
|
||||
}
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (senderId != null && senderId == currentUserId) {
|
||||
Log.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
||||
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
||||
return@launch
|
||||
}
|
||||
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
|
||||
@@ -61,25 +67,23 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
NotificationHelper.fetchAndNotify(applicationContext)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Log.d("FromChatFCM", "onNewToken: $token")
|
||||
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
settings.putString("pending_fcm_token", token)
|
||||
uploadPendingFcmTokenIfAvailable()
|
||||
Log.d("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
||||
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||
}
|
||||
|
||||
super.onNewToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
@@ -25,6 +24,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.MainActivity
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.R
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
@@ -145,16 +145,16 @@ object NotificationHelper {
|
||||
dmMessageId: Int? = null,
|
||||
dmSenderName: String? = null
|
||||
) {
|
||||
Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||
|
||||
try {
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
Log.d(
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}"
|
||||
)
|
||||
if (currentUserId == -1) {
|
||||
Log.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
|
||||
Logger.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ object NotificationHelper {
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != currentUserId }
|
||||
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||
if (messages.isNotEmpty()) {
|
||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
@@ -171,7 +171,7 @@ object NotificationHelper {
|
||||
displayNotifications(context, messages)
|
||||
}
|
||||
} else {
|
||||
Log.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||
}
|
||||
|
||||
if (includeDmMessages) {
|
||||
@@ -180,14 +180,14 @@ object NotificationHelper {
|
||||
} catch (e: Exception) {
|
||||
if (e is ClientRequestException && e.response.status.value == 401) {
|
||||
try {
|
||||
Log.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
|
||||
Logger.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
|
||||
ApiClient.loadPersistedData()
|
||||
val retryMessages = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != settings.getInt("current_user_id", -1) }
|
||||
Log.d(
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||
)
|
||||
@@ -202,10 +202,10 @@ object NotificationHelper {
|
||||
}
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
Log.e("NotificationHelper", "fetchAndNotify retry failed", e)
|
||||
Logger.e("NotificationHelper", "fetchAndNotify retry failed", e)
|
||||
}
|
||||
}
|
||||
Log.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
|
||||
Logger.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ object NotificationHelper {
|
||||
}
|
||||
|
||||
if (sinceId == null || sinceId < 0) {
|
||||
Log.d("NotificationHelper", "fetchAndNotifyDirectMessages: no dm watermark yet, skipping broad dm sync")
|
||||
Logger.d("NotificationHelper", "fetchAndNotifyDirectMessages: no dm watermark yet, skipping broad dm sync")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ object NotificationHelper {
|
||||
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
|
||||
throw throwable
|
||||
}
|
||||
Log.e(
|
||||
Logger.e(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotifyDirectMessages: failed to fetch dm messages for since=$sinceId: ${throwable.message}",
|
||||
throwable
|
||||
@@ -252,7 +252,7 @@ object NotificationHelper {
|
||||
dmSenderName: String?
|
||||
) {
|
||||
val dmMessages = response.messages
|
||||
Log.d("NotificationHelper", "fetchAndNotifyDirectMessages: fetched ${dmMessages.size} dm messages")
|
||||
Logger.i("NotificationHelper", "fetchAndNotifyDirectMessages: fetched ${dmMessages.size} dm messages")
|
||||
if (dmMessages.isEmpty()) {
|
||||
return
|
||||
}
|
||||
@@ -271,7 +271,7 @@ object NotificationHelper {
|
||||
val shownDmKey = "dm:$envelopeId"
|
||||
|
||||
if (shownDm.contains(shownDmKey) || envelopeId <= latestMessageId) {
|
||||
Log.d(
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"Direct notification skipped: already shown envelopeId=$envelopeId"
|
||||
)
|
||||
@@ -283,7 +283,7 @@ object NotificationHelper {
|
||||
}.getOrElse { throwable ->
|
||||
when (throwable) {
|
||||
is DmCiphertextCorruptedException -> {
|
||||
Log.w(
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"DM decrypt failed for envelopeId=$envelopeId"
|
||||
)
|
||||
@@ -291,7 +291,7 @@ object NotificationHelper {
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}",
|
||||
throwable
|
||||
@@ -355,16 +355,16 @@ object NotificationHelper {
|
||||
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (!isDirectMessage && senderId != null && senderId == currentUserId) {
|
||||
Log.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId")
|
||||
Logger.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId")
|
||||
return@launch
|
||||
}
|
||||
if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) {
|
||||
Log.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId")
|
||||
Logger.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (isPublicChatVisible && !allowWhenPublicChatVisible) {
|
||||
Log.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
|
||||
Logger.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ object NotificationHelper {
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.w(
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification skipped: POST_NOTIFICATIONS permission missing"
|
||||
)
|
||||
@@ -385,7 +385,7 @@ object NotificationHelper {
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
val shownKey = if (isDirectMessage) "dm:${messageId}" else messageId?.toString()
|
||||
if (messageId != null && shown.contains(shownKey)) {
|
||||
Log.d(
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification skipped: already shown messageId=$messageId"
|
||||
)
|
||||
@@ -446,20 +446,20 @@ object NotificationHelper {
|
||||
.build()
|
||||
)
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
Log.d(
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification shown title=$title sender=$senderName messageId=$messageId"
|
||||
"Fallback push notification shown messageId=$messageId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||
Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||
|
||||
// Don't show notifications if user is currently viewing the public chat
|
||||
if (isPublicChatVisible) {
|
||||
Log.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ object NotificationHelper {
|
||||
msg.user_id != currentUserId // Not from current user
|
||||
}
|
||||
if (newMessages.isEmpty()) {
|
||||
Log.d(
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: no new messages after filters for user=$currentUserId"
|
||||
)
|
||||
@@ -494,7 +494,7 @@ object NotificationHelper {
|
||||
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
||||
|
||||
newMessageCount = newMessages.size
|
||||
Log.d(
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
||||
)
|
||||
@@ -553,7 +553,7 @@ object NotificationHelper {
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
Log.w(
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||
)
|
||||
@@ -561,7 +561,7 @@ object NotificationHelper {
|
||||
}
|
||||
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
Log.d("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
|
||||
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ package ru.fromchat.notifications
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
|
||||
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
|
||||
@@ -21,9 +21,9 @@ private const val CHAT_TYPE_DM = "dm"
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.e(
|
||||
Logger.d(
|
||||
"NotificationReply",
|
||||
"onReceive called action=${intent.action} extras=${intent.extras?.keySet()?.joinToString()}"
|
||||
"onReceive action=${intent.action} extras=${intent.extras?.keySet()?.joinToString()}",
|
||||
)
|
||||
|
||||
val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input ->
|
||||
@@ -33,12 +33,12 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
?.toString()
|
||||
?.trim()
|
||||
?: run {
|
||||
Log.w("NotificationReply", "No inline reply text found")
|
||||
Logger.w("NotificationReply", "No inline reply text found")
|
||||
return
|
||||
}
|
||||
|
||||
if (replyText.isBlank()) {
|
||||
Log.w("NotificationReply", "Inline reply text is blank")
|
||||
Logger.w("NotificationReply", "Inline reply text is blank")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val targetDmUserId = intent.getIntExtra(EXTRA_REPLY_DM_USER_ID, -1)
|
||||
val parentMessageId = intent.getIntExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, -1).takeIf { it > 0 }
|
||||
|
||||
Log.d("NotificationReply", "Received reply for $chatType: $replyText")
|
||||
Logger.d("NotificationReply", "Received reply for $chatType (length=${replyText.length})")
|
||||
NotificationManagerCompat.from(context).cancel(NotificationHelper.summaryNotificationId())
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -64,7 +64,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
replyToId = parentMessageId
|
||||
)
|
||||
} else {
|
||||
Log.w("NotificationReply", "Received DM reply without recipient id; skipping send")
|
||||
Logger.w("NotificationReply", "Received DM reply without recipient id; skipping send")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +73,9 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
replyToId = parentMessageId
|
||||
)
|
||||
}
|
||||
Log.d("NotificationReply", "Reply dispatch attempt completed for $chatType")
|
||||
Logger.i("NotificationReply", "Reply dispatch attempt completed for $chatType")
|
||||
} catch (e: Exception) {
|
||||
Log.w("NotificationReply", "Failed to send reply", e)
|
||||
Logger.w("NotificationReply", "Failed to send reply", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
package ru.fromchat
|
||||
|
||||
import android.util.Log
|
||||
import ru.fromchat.logging.AppLogLevel
|
||||
import ru.fromchat.logging.AppLogStore
|
||||
|
||||
actual object Logger {
|
||||
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Debug, tag, message, throwable)
|
||||
Log.d(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Info, tag, message, throwable)
|
||||
Log.i(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Warn, tag, message, throwable)
|
||||
Log.w(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Error, tag, message, throwable)
|
||||
Log.e(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun f(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Fatal, tag, message, throwable)
|
||||
Log.wtf(tag, message, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import android.util.Log
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
@@ -11,6 +10,7 @@ import io.ktor.client.request.setBody
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import kotlin.coroutines.resume
|
||||
@@ -41,10 +41,10 @@ private suspend fun postFcmToken(token: String): Boolean {
|
||||
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
||||
}
|
||||
.body<SimpleStatusResponse>()
|
||||
Log.d("FcmReg", "Uploaded FCM token to server: ...$suffix")
|
||||
Logger.i("FcmReg", "Uploaded FCM token to server: ...$suffix")
|
||||
true
|
||||
}.getOrElse { e ->
|
||||
Log.e("FcmReg", "Failed to upload FCM token: ${e.message}", e)
|
||||
Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,8 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
|
||||
try {
|
||||
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
|
||||
|
||||
// Only upload if we have auth token
|
||||
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
|
||||
Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
|
||||
Logger.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -63,16 +62,16 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
|
||||
settings.putString(CURRENT_FCM_TOKEN_KEY, pending)
|
||||
settings.remove(PENDING_FCM_TOKEN_KEY)
|
||||
} else {
|
||||
Log.d("FcmReg", "Deferring pending FCM token upload")
|
||||
Logger.d("FcmReg", "Deferring pending FCM token upload")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
|
||||
Logger.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun ensureFcmTokenRegistered(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (ApiClient.token.isNullOrEmpty()) {
|
||||
Log.d("FcmReg", "Auth token missing; skip explicit FCM sync")
|
||||
Logger.d("FcmReg", "Auth token missing; skip explicit FCM sync")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
@@ -92,19 +91,19 @@ actual suspend fun ensureFcmTokenRegistered(): Boolean = withContext(Dispatchers
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e("FcmReg", "ensureFcmTokenRegistered error: ${e.message}")
|
||||
Logger.e("FcmReg", "ensureFcmTokenRegistered error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (ApiClient.token.isNullOrEmpty()) {
|
||||
Log.d("FcmReg", "Auth token missing; cannot unregister FCM token")
|
||||
Logger.d("FcmReg", "Auth token missing; cannot unregister FCM token")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim()
|
||||
Log.d("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
|
||||
Logger.i("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
|
||||
return@withContext runCatching {
|
||||
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
|
||||
header("Content-Type", "application/json")
|
||||
@@ -118,7 +117,7 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc
|
||||
}
|
||||
true
|
||||
}.getOrElse { e ->
|
||||
Log.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
|
||||
Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ actual suspend fun openCachedAttachmentFile(
|
||||
|
||||
try {
|
||||
context.startActivity(primary)
|
||||
Logger.d(tag, "startActivity ok mime=$resolvedMime uri=$contentUri name=$nameForMime")
|
||||
Logger.i(tag, "startActivity ok mime=$resolvedMime name=$nameForMime")
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(tag, "startActivity primary failed, falling back mime=$resolvedMime uri=$contentUri", t)
|
||||
context.startActivity(fallback)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
internal actual fun gzipCompress(input: ByteArray): ByteArray {
|
||||
val output = ByteArrayOutputStream(input.size)
|
||||
GZIPOutputStream(output).use { gzip -> gzip.write(input) }
|
||||
return output.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal actual object LogFileOps {
|
||||
actual fun readText(path: String): String {
|
||||
val file = File(path)
|
||||
if (!file.isFile) return ""
|
||||
return runCatching { file.readText() }.getOrDefault("")
|
||||
}
|
||||
|
||||
actual fun readBytes(path: String): ByteArray {
|
||||
val file = File(path)
|
||||
if (!file.isFile) return ByteArray(0)
|
||||
return runCatching { file.readBytes() }.getOrDefault(ByteArray(0))
|
||||
}
|
||||
|
||||
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.IO) {
|
||||
val source = File(sourcePath)
|
||||
if (!source.isFile) return@withContext
|
||||
val dest = File(destinationPath)
|
||||
dest.parentFile?.mkdirs()
|
||||
FileInputStream(source).use { input ->
|
||||
GZIPOutputStream(dest.outputStream()).use { gzip ->
|
||||
input.copyTo(gzip)
|
||||
}
|
||||
}
|
||||
source.delete()
|
||||
}
|
||||
|
||||
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
gunzipToByteArray(path, onProgress).decodeToString()
|
||||
}
|
||||
|
||||
actual suspend fun gunzipToFile(
|
||||
sourcePath: String,
|
||||
destinationPath: String,
|
||||
onProgress: (Float) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val bytes = gunzipToByteArray(sourcePath, onProgress)
|
||||
val dest = File(destinationPath)
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.writeBytes(bytes)
|
||||
}
|
||||
|
||||
actual suspend fun zipFiles(
|
||||
entries: List<Pair<String, String>>,
|
||||
destinationPath: String,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (entries.isEmpty()) return@withContext
|
||||
val dest = File(destinationPath)
|
||||
dest.parentFile?.mkdirs()
|
||||
ZipOutputStream(dest.outputStream()).use { zip ->
|
||||
entries.forEach { (entryName, sourcePath) ->
|
||||
val source = File(sourcePath)
|
||||
if (!source.isFile) return@forEach
|
||||
zip.putNextEntry(ZipEntry(entryName))
|
||||
FileInputStream(source).use { input -> input.copyTo(zip) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
|
||||
val file = File(path)
|
||||
if (!file.isFile) {
|
||||
onProgress(1f)
|
||||
return ByteArray(0)
|
||||
}
|
||||
val total = file.length().coerceAtLeast(1L)
|
||||
val output = ByteArrayOutputStream()
|
||||
FileInputStream(file).use { input ->
|
||||
GZIPInputStream(input).use { gzip ->
|
||||
val buffer = ByteArray(8_192)
|
||||
var read: Int
|
||||
var consumed = 0L
|
||||
while (gzip.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
consumed = (consumed + read).coerceAtMost(total)
|
||||
onProgress((consumed.toFloat() / total).coerceIn(0f, 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
onProgress(1f)
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
import java.io.File
|
||||
|
||||
actual object LogShare {
|
||||
actual fun shareText(title: String, text: String) {
|
||||
val context = UtilsLibrary.context
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, title)
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
actual fun shareFile(title: String, filePath: String, mimeType: String) {
|
||||
val context = UtilsLibrary.context
|
||||
val file = File(filePath)
|
||||
if (!file.isFile) {
|
||||
shareText(title, "")
|
||||
return
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.attachment_files",
|
||||
file,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_SUBJECT, title)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
@@ -397,7 +397,7 @@ actual fun CallMediaLayer(
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(
|
||||
Logger.i(
|
||||
TAG,
|
||||
"RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " +
|
||||
"(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)",
|
||||
@@ -435,7 +435,7 @@ actual fun CallMediaLayer(
|
||||
reconnectAttempt = 0
|
||||
reconnectGeneration += 1 // invalidate any pending reconnect
|
||||
|
||||
Logger.d(
|
||||
Logger.i(
|
||||
TAG,
|
||||
"RoomScope onConnected state=${room.state} micReq=$micRequestedOn " +
|
||||
"micEn=${room.localParticipant.isMicrophoneEnabled}",
|
||||
@@ -458,7 +458,7 @@ actual fun CallMediaLayer(
|
||||
connectionStatusText = null
|
||||
reconnectAttempt = 0
|
||||
reconnectGeneration += 1 // invalidate any pending reconnect
|
||||
Logger.d(TAG, "RoomEvent.Connected")
|
||||
Logger.i(TAG, "RoomEvent.Connected")
|
||||
}
|
||||
is RoomEvent.Disconnected -> {
|
||||
val detail = event.error?.message ?: event.reason.toString()
|
||||
@@ -483,7 +483,7 @@ actual fun CallMediaLayer(
|
||||
}
|
||||
is RoomEvent.Reconnected -> {
|
||||
connectionStatusText = null
|
||||
Logger.d(TAG, "RoomEvent.Reconnected")
|
||||
Logger.i(TAG, "RoomEvent.Reconnected")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,56 @@
|
||||
<string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string>
|
||||
<string name="settings_next">Далее</string>
|
||||
<string name="settings_hub_about_sub">Версия, ссылки и другое</string>
|
||||
<string name="logs_title">Журнал</string>
|
||||
<string name="settings_hub_logs_sub">Просмотр, отправка и очистка журнала</string>
|
||||
<string name="logs_empty">Записей пока нет</string>
|
||||
<string name="logs_share">Поделиться журналом</string>
|
||||
<string name="logs_rotate">Ротация журнала</string>
|
||||
<string name="logs_rotate_confirm_title">Ротировать файл журнала?</string>
|
||||
<string name="logs_rotate_confirm_body">Текущий журнал будет заархивирован и начнётся новый пустой файл.</string>
|
||||
<string name="logs_clean">Очистить журнал</string>
|
||||
<string name="logs_copied">Скопировано в буфер обмена</string>
|
||||
<string name="logs_clean_title">Очистка журнала</string>
|
||||
<string name="logs_clean_apply">Очистить</string>
|
||||
<string name="logs_clean_mode_size">По общему размеру</string>
|
||||
<string name="logs_clean_mode_all">Удалить всё</string>
|
||||
<string name="logs_clean_mode_entries">По числу записей</string>
|
||||
<string name="logs_clean_mode_date">До даты</string>
|
||||
<string name="logs_clean_size_body">Удаляет старые архивы и записи, пока общий размер журнала не станет меньше лимита.</string>
|
||||
<string name="logs_clean_size_mb">Лимит: %1$d МБ</string>
|
||||
<string name="logs_clean_all_body">Удаляет текущий файл журнала и все архивы.</string>
|
||||
<string name="logs_clean_entries_body">Оставляет только самые новые записи в текущем файле.</string>
|
||||
<string name="logs_clean_entries_count">Оставить новых: %1$d</string>
|
||||
<string name="logs_clean_date_body">Удаляет записи и архивы до выбранной даты (ваш часовой пояс).</string>
|
||||
<string name="logs_clean_date_year">Год</string>
|
||||
<string name="logs_clean_date_month">Месяц</string>
|
||||
<string name="logs_clean_date_day">День</string>
|
||||
<string name="logs_share_how_title">Как отправить журнал?</string>
|
||||
<string name="logs_share_uncompressed">Без сжатия</string>
|
||||
<string name="logs_share_uncompressed_desc">Проще читать без дополнительных программ</string>
|
||||
<string name="logs_share_compressed">Сжатый</string>
|
||||
<string name="logs_share_compressed_desc">Меньший размер, но нужен gzip для просмотра</string>
|
||||
<string name="logs_decompressing">Распаковка…</string>
|
||||
<string name="logs_files_title">Файлы журнала</string>
|
||||
<string name="logs_browse_files_cd">Просмотр файлов журнала</string>
|
||||
<string name="logs_selected_count">Выбрано: %1$d</string>
|
||||
<string name="logs_level_verbose">verbose</string>
|
||||
<string name="logs_level_debug">debug</string>
|
||||
<string name="logs_level_info">info</string>
|
||||
<string name="logs_level_warn">warning</string>
|
||||
<string name="logs_level_error">error</string>
|
||||
<string name="logs_level_fatal">fatal</string>
|
||||
<string name="logs_open">Открыть</string>
|
||||
<string name="logs_delete_file_confirm_title">Удалить файл журнала?</string>
|
||||
<string name="logs_delete_file_confirm_body">Файл будет безвозвратно удалён с устройства.</string>
|
||||
<string name="logs_delete_files_confirm_title">Удалить выбранные файлы журнала?</string>
|
||||
<string name="logs_delete_files_confirm_body">Будет безвозвратно удалено файлов: %1$d.</string>
|
||||
<string name="logs_clear_all_cd">Очистить все файлы логов</string>
|
||||
<string name="logs_clear_all_confirm_title">Очистить все файлы логов?</string>
|
||||
<string name="logs_clear_all_confirm_body">Будут удалены текущий лог и все архивы.</string>
|
||||
<string name="logs_file_size_kb">%1$d КБ</string>
|
||||
<string name="logs_file_size_mb">%1$s МБ</string>
|
||||
<string name="logs_scroll_to_bottom_cd">Прокрутить к последним записям</string>
|
||||
|
||||
<string name="settings_account_title">Аккаунт</string>
|
||||
<string name="settings_account_logout_confirm_title">Выйти?</string>
|
||||
|
||||
@@ -359,6 +359,56 @@
|
||||
<string name="settings_security_step_confirm_body">Type your new password again to make sure it matches.</string>
|
||||
<string name="settings_next">Next</string>
|
||||
<string name="settings_hub_about_sub">Version, links, and more</string>
|
||||
<string name="logs_title">Logs</string>
|
||||
<string name="settings_hub_logs_sub">View, share, and manage app logs</string>
|
||||
<string name="logs_empty">No log entries yet</string>
|
||||
<string name="logs_share">Share logs</string>
|
||||
<string name="logs_rotate">Rotate logs</string>
|
||||
<string name="logs_rotate_confirm_title">Rotate log file?</string>
|
||||
<string name="logs_rotate_confirm_body">The current log will be archived and a new empty log file will be started.</string>
|
||||
<string name="logs_clean">Clean logs</string>
|
||||
<string name="logs_copied">Copied to clipboard</string>
|
||||
<string name="logs_clean_title">Clean logs</string>
|
||||
<string name="logs_clean_apply">Clean</string>
|
||||
<string name="logs_clean_mode_size">By total size</string>
|
||||
<string name="logs_clean_mode_all">Delete everything</string>
|
||||
<string name="logs_clean_mode_entries">By entry count</string>
|
||||
<string name="logs_clean_mode_date">Before date</string>
|
||||
<string name="logs_clean_size_body">Delete oldest archives and entries until total log storage is below the limit.</string>
|
||||
<string name="logs_clean_size_mb">Limit: %1$d MB</string>
|
||||
<string name="logs_clean_all_body">Deletes the current log file and all rotated archives.</string>
|
||||
<string name="logs_clean_entries_body">Keep only the newest entries in the current log file.</string>
|
||||
<string name="logs_clean_entries_count">Keep newest: %1$d</string>
|
||||
<string name="logs_clean_date_body">Delete entries and archives before the selected date (your time zone).</string>
|
||||
<string name="logs_clean_date_year">Year</string>
|
||||
<string name="logs_clean_date_month">Month</string>
|
||||
<string name="logs_clean_date_day">Day</string>
|
||||
<string name="logs_share_how_title">How do you want to send the logs?</string>
|
||||
<string name="logs_share_uncompressed">Uncompressed</string>
|
||||
<string name="logs_share_uncompressed_desc">Easier to read without any additional software</string>
|
||||
<string name="logs_share_compressed">Compressed</string>
|
||||
<string name="logs_share_compressed_desc">Smaller file size, but requires gzip to view</string>
|
||||
<string name="logs_decompressing">Decompressing…</string>
|
||||
<string name="logs_files_title">Log files</string>
|
||||
<string name="logs_browse_files_cd">Browse log files</string>
|
||||
<string name="logs_selected_count">%1$d selected</string>
|
||||
<string name="logs_level_verbose">verbose</string>
|
||||
<string name="logs_level_debug">debug</string>
|
||||
<string name="logs_level_info">info</string>
|
||||
<string name="logs_level_warn">warning</string>
|
||||
<string name="logs_level_error">error</string>
|
||||
<string name="logs_level_fatal">fatal</string>
|
||||
<string name="logs_open">Open</string>
|
||||
<string name="logs_delete_file_confirm_title">Delete log file?</string>
|
||||
<string name="logs_delete_file_confirm_body">This file will be permanently removed from the device.</string>
|
||||
<string name="logs_delete_files_confirm_title">Delete selected log files?</string>
|
||||
<string name="logs_delete_files_confirm_body">%1$d files will be permanently removed from the device.</string>
|
||||
<string name="logs_clear_all_cd">Clear all log files</string>
|
||||
<string name="logs_clear_all_confirm_title">Clear all log files?</string>
|
||||
<string name="logs_clear_all_confirm_body">This deletes the current log and all rotated archives.</string>
|
||||
<string name="logs_file_size_kb">%1$d KB</string>
|
||||
<string name="logs_file_size_mb">%1$s MB</string>
|
||||
<string name="logs_scroll_to_bottom_cd">Scroll to latest logs</string>
|
||||
|
||||
<string name="settings_account_title">Account</string>
|
||||
<string name="settings_account_logout_confirm_title">Log out?</string>
|
||||
|
||||
@@ -5,4 +5,5 @@ expect object Logger {
|
||||
fun i(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun w(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun f(tag: String, message: String, throwable: Throwable? = null)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ object UpdateSyncManager {
|
||||
val startSeq = _lastSeq.value
|
||||
|
||||
try {
|
||||
Logger.d("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
|
||||
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
|
||||
if (startSeq > 0) {
|
||||
ConnectionStateStore.onUpdating(start = true)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ object UpdateSyncManager {
|
||||
if (data != null) {
|
||||
runCatching {
|
||||
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
||||
Logger.d(
|
||||
Logger.i(
|
||||
"UpdateSyncManager",
|
||||
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ object CallStore {
|
||||
}
|
||||
val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
if (_ui.value is CallUiState.InCall) return
|
||||
Logger.d(TAG, "call_signaling → Incoming from=$fromUserId room=$roomName")
|
||||
Logger.i(TAG, "call_signaling → Incoming from=$fromUserId room=$roomName")
|
||||
_ui.value = CallUiState.Incoming(
|
||||
fromUserId = fromUserId,
|
||||
fromUsername = fromUsername,
|
||||
@@ -126,7 +126,7 @@ object CallStore {
|
||||
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
|
||||
return
|
||||
}
|
||||
Logger.d(TAG, "startOutgoingCall(peer=$peerUserId)")
|
||||
Logger.i(TAG, "startOutgoingCall(peer=$peerUserId)")
|
||||
scope.launch {
|
||||
// Stay on underlying UI until the room is ready; do not block on callee answering.
|
||||
runCatching {
|
||||
@@ -146,7 +146,7 @@ object CallStore {
|
||||
)
|
||||
}
|
||||
}.onSuccess { session ->
|
||||
Logger.d(
|
||||
Logger.i(
|
||||
TAG,
|
||||
"startOutgoingCall → InCall peer=${session.peerUserId} room=${session.roomName}",
|
||||
)
|
||||
@@ -183,7 +183,7 @@ object CallStore {
|
||||
)
|
||||
}
|
||||
}.onSuccess { session ->
|
||||
Logger.d(
|
||||
Logger.i(
|
||||
TAG,
|
||||
"acceptIncoming → InCall peer=${session.peerUserId} room=${session.roomName}",
|
||||
)
|
||||
|
||||
@@ -105,6 +105,10 @@ object WebSocketManager {
|
||||
if (AppForeground.isInForeground.value) Logger.d(TAG, message)
|
||||
}
|
||||
|
||||
private fun logI(message: String) {
|
||||
if (AppForeground.isInForeground.value) Logger.i(TAG, message)
|
||||
}
|
||||
|
||||
private fun logW(message: String, throwable: Throwable? = null) {
|
||||
if (AppForeground.isInForeground.value) Logger.w(TAG, message, throwable)
|
||||
}
|
||||
@@ -197,7 +201,7 @@ object WebSocketManager {
|
||||
) {
|
||||
session = this
|
||||
connecting = false
|
||||
logD("WebSocket connected. connecting set to false")
|
||||
logI("WebSocket connected. connecting set to false")
|
||||
ConnectionStateStore.onConnected()
|
||||
|
||||
logD("Sending WebSocket ping for authentication")
|
||||
@@ -342,7 +346,7 @@ object WebSocketManager {
|
||||
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
logD("shutdown() called. Cancelling scope.")
|
||||
logI("shutdown() called. Cancelling scope.")
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@@ -353,7 +357,7 @@ object WebSocketManager {
|
||||
session?.cancel()
|
||||
session = null
|
||||
connecting = false
|
||||
logD("Disconnected. session set to null, connecting set to false, connectionJob set to null")
|
||||
logI("Disconnected. session set to null, connecting set to false, connectionJob set to null")
|
||||
}
|
||||
|
||||
fun onNetworkLost() {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
private val FORMATTED_PRIMARY_LINE_REGEX = Regex(
|
||||
"""^(\d{2}\.\d{2}\.\d{4}) (\d{2}:\d{2}) \[([A-Z]+)\] (\S+) (.*)$""",
|
||||
)
|
||||
|
||||
data class AppLogEntry(
|
||||
val id: Long,
|
||||
val timestamp: Instant,
|
||||
val level: AppLogLevel,
|
||||
val tag: String,
|
||||
val message: String,
|
||||
val stackTrace: String? = null,
|
||||
) {
|
||||
fun formattedLine(): String = buildString {
|
||||
append(formatLogTimestamp(timestamp))
|
||||
append(' ')
|
||||
append(level.bracketLabel())
|
||||
append(' ')
|
||||
append(tag)
|
||||
append(' ')
|
||||
append(message)
|
||||
stackTrace?.takeIf { it.isNotBlank() }?.let { trace ->
|
||||
append('\n')
|
||||
append(trace.prependIndent("\t"))
|
||||
}
|
||||
}
|
||||
|
||||
fun displayText(): String = formattedLine()
|
||||
}
|
||||
|
||||
fun AppLogLevel.bracketLabel(): String = "[${bracketLevelName()}]"
|
||||
|
||||
fun AppLogLevel.bracketLevelName(): String = when (this) {
|
||||
AppLogLevel.Debug -> "DEBUG"
|
||||
AppLogLevel.Fatal -> "FATAL"
|
||||
else -> letter.toString()
|
||||
}
|
||||
|
||||
fun formatLogTimestamp(
|
||||
instant: Instant,
|
||||
timeZone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): String {
|
||||
val local = instant.toLocalDateTime(timeZone)
|
||||
val date = local.date
|
||||
val day = date.day.toString().padStart(2, '0')
|
||||
val month = date.month.number.toString().padStart(2, '0')
|
||||
val hour = local.hour.toString().padStart(2, '0')
|
||||
val minute = local.minute.toString().padStart(2, '0')
|
||||
return "$day.$month.${date.year} $hour:$minute"
|
||||
}
|
||||
|
||||
internal fun parseFormattedPrimaryLine(line: String, id: Long): AppLogEntry? {
|
||||
val match = FORMATTED_PRIMARY_LINE_REGEX.matchEntire(line) ?: return null
|
||||
val (datePart, timePart, levelPart, tag, message) = match.destructured
|
||||
val level = levelFromBracket(levelPart) ?: return null
|
||||
val timestamp = parseLocalLogTimestamp(datePart, timePart) ?: return null
|
||||
return AppLogEntry(
|
||||
id = id,
|
||||
timestamp = timestamp,
|
||||
level = level,
|
||||
tag = tag,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseLocalLogTimestamp(datePart: String, timePart: String): Instant? {
|
||||
val dateParts = datePart.split('.')
|
||||
val timeParts = timePart.split(':')
|
||||
if (dateParts.size != 3 || timeParts.size != 2) return null
|
||||
val localDate = runCatching {
|
||||
LocalDate(dateParts[2].toInt(), dateParts[1].toInt(), dateParts[0].toInt())
|
||||
}.getOrNull() ?: return null
|
||||
val localTime = runCatching {
|
||||
LocalTime(timeParts[0].toInt(), timeParts[1].toInt())
|
||||
}.getOrNull() ?: return null
|
||||
return runCatching {
|
||||
localDate.atTime(localTime).toInstant(TimeZone.currentSystemDefault())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun levelFromBracket(levelPart: String): AppLogLevel? = when (levelPart) {
|
||||
"DEBUG" -> AppLogLevel.Debug
|
||||
"FATAL" -> AppLogLevel.Fatal
|
||||
else -> AppLogLevel.fromLetter(levelPart.firstOrNull() ?: return null)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
enum class AppLogLevel {
|
||||
Verbose,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Fatal,
|
||||
;
|
||||
|
||||
val letter: Char
|
||||
get() = when (this) {
|
||||
Verbose -> 'V'
|
||||
Debug -> 'D'
|
||||
Info -> 'I'
|
||||
Warn -> 'W'
|
||||
Error -> 'E'
|
||||
Fatal -> 'F'
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLetter(letter: Char): AppLogLevel? = when (letter.uppercaseChar()) {
|
||||
'V' -> Verbose
|
||||
'D' -> Debug
|
||||
'I' -> Info
|
||||
'W' -> Warn
|
||||
'E' -> Error
|
||||
'F' -> Fatal
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
enum class LogCleanMode {
|
||||
Size,
|
||||
All,
|
||||
Entries,
|
||||
Date,
|
||||
}
|
||||
|
||||
data class LogCleanRequest(
|
||||
val mode: LogCleanMode,
|
||||
val maxTotalBytes: Long = 5L * 1024 * 1024,
|
||||
val keepNewestEntries: Int = 1_000,
|
||||
val deleteBefore: LocalDate? = null,
|
||||
)
|
||||
|
||||
data class LogFileInfo(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val sizeBytes: Long,
|
||||
val isGzip: Boolean,
|
||||
)
|
||||
|
||||
enum class LogShareCompression {
|
||||
Uncompressed,
|
||||
Compressed,
|
||||
}
|
||||
|
||||
object AppLogStore {
|
||||
private const val MAX_MEMORY_ENTRIES = 8_000
|
||||
private const val CONTINUATION_PREFIX = "\t"
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val _entries = MutableStateFlow<List<AppLogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<AppLogEntry>> = _entries.asStateFlow()
|
||||
|
||||
private var loadedFromDisk = false
|
||||
private var nextEntryId = 0L
|
||||
|
||||
private val writeLock = Any()
|
||||
|
||||
fun record(
|
||||
level: AppLogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable? = null,
|
||||
) {
|
||||
val entry = AppLogEntry(
|
||||
id = nextEntryId++,
|
||||
timestamp = Clock.System.now(),
|
||||
level = level,
|
||||
tag = tag.trim().ifEmpty { "App" },
|
||||
message = message,
|
||||
stackTrace = throwable?.stackTraceToString(),
|
||||
)
|
||||
synchronized(writeLock) {
|
||||
appendEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendEntry(entry: AppLogEntry) {
|
||||
val lineBytes = (entry.formattedLine() + "\n").encodeToByteArray()
|
||||
PlatformFileSystem.appendBytes(FromChatLogDirs.currentLogPath(), lineBytes)
|
||||
val updated = (_entries.value + entry).let { list ->
|
||||
if (list.size <= MAX_MEMORY_ENTRIES) list else list.takeLast(MAX_MEMORY_ENTRIES)
|
||||
}
|
||||
_entries.value = updated
|
||||
}
|
||||
|
||||
suspend fun ensureLoaded() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
if (loadedFromDisk) return@withContext
|
||||
val text = runCatching { LogFileOps.readText(FromChatLogDirs.currentLogPath()) }
|
||||
.getOrDefault("")
|
||||
setEntriesFromParsed(parseLogText(text))
|
||||
loadedFromDisk = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshFromDisk() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val text = runCatching { LogFileOps.readText(FromChatLogDirs.currentLogPath()) }
|
||||
.getOrDefault("")
|
||||
setEntriesFromParsed(parseLogText(text))
|
||||
loadedFromDisk = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun rotate() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val currentPath = FromChatLogDirs.currentLogPath()
|
||||
if (!PlatformFileSystem.exists(currentPath) || PlatformFileSystem.fileSize(currentPath) == 0L) {
|
||||
return@withLock
|
||||
}
|
||||
val stamp = Clock.System.now().toString().replace(':', '-')
|
||||
val archivePath = "${FromChatLogDirs.logsDirectoryPath()}/log-$stamp.log.gz"
|
||||
LogFileOps.gzipFile(currentPath, archivePath)
|
||||
PlatformFileSystem.delete(currentPath)
|
||||
_entries.value = emptyList()
|
||||
loadedFromDisk = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clean(request: LogCleanRequest) = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
when (request.mode) {
|
||||
LogCleanMode.All -> wipeAllLogs()
|
||||
LogCleanMode.Size -> cleanBySize(request.maxTotalBytes.coerceAtLeast(0L))
|
||||
LogCleanMode.Entries -> cleanByEntries(request.keepNewestEntries.coerceAtLeast(0))
|
||||
LogCleanMode.Date -> cleanByDate(request.deleteBefore)
|
||||
}
|
||||
refreshEntriesLocked()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportAllText(): String = withContext(Dispatchers.IO) {
|
||||
mutex.withLock { buildExportTextLocked() }
|
||||
}
|
||||
|
||||
suspend fun writeExportFile(): String = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val exportPath = FromChatLogDirs.exportFilePath()
|
||||
PlatformFileSystem.writeBytes(exportPath, buildExportTextLocked().encodeToByteArray())
|
||||
exportPath
|
||||
}
|
||||
}
|
||||
|
||||
fun listLogFiles(): List<LogFileInfo> {
|
||||
val dir = FromChatLogDirs.logsDirectoryPath()
|
||||
return PlatformFileSystem.listFileNamesInDirectory(dir)
|
||||
.filter { it != FromChatLogDirs.EXPORT_FILE && !it.startsWith("share-") }
|
||||
.sortedByDescending { it }
|
||||
.map { name ->
|
||||
val path = "$dir/$name"
|
||||
LogFileInfo(
|
||||
name = name,
|
||||
path = path,
|
||||
sizeBytes = PlatformFileSystem.fileSize(path),
|
||||
isGzip = name.endsWith(".gz"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasFilesBesidesCurrent(): Boolean =
|
||||
listLogFiles().any { it.name != FromChatLogDirs.CURRENT_LOG_FILE }
|
||||
|
||||
suspend fun loadEntriesFromPath(
|
||||
path: String,
|
||||
onProgress: (Float) -> Unit = {},
|
||||
): List<AppLogEntry> = withContext(Dispatchers.IO) {
|
||||
val text = if (path.endsWith(".gz")) {
|
||||
LogFileOps.readGzipText(path, onProgress)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
LogFileOps.readText(path)
|
||||
}
|
||||
parseLogText(text)
|
||||
}
|
||||
|
||||
suspend fun deleteLogFile(path: String) = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
PlatformFileSystem.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prepareMultiFileShareZip(paths: List<String>): String = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val stamp = Clock.System.now().toString().replace(':', '-')
|
||||
val zipPath = FromChatLogDirs.tempShareFilePath("$stamp.zip")
|
||||
val entries = paths.map { path -> path.substringAfterLast('/') to path }
|
||||
LogFileOps.zipFiles(entries, zipPath)
|
||||
zipPath
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prepareSharePath(
|
||||
sourcePath: String?,
|
||||
isCurrentLog: Boolean,
|
||||
compression: LogShareCompression,
|
||||
entries: List<AppLogEntry>? = null,
|
||||
onProgress: (Float) -> Unit = {},
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val stamp = Clock.System.now().toString().replace(':', '-')
|
||||
when {
|
||||
isCurrentLog -> {
|
||||
val text = entries?.joinToString("\n") { it.formattedLine() }
|
||||
?: runCatching { LogFileOps.readText(FromChatLogDirs.currentLogPath()) }
|
||||
.getOrDefault("")
|
||||
when (compression) {
|
||||
LogShareCompression.Uncompressed -> {
|
||||
val path = FromChatLogDirs.tempShareFilePath("$stamp.log")
|
||||
PlatformFileSystem.writeBytes(path, text.encodeToByteArray())
|
||||
path
|
||||
}
|
||||
LogShareCompression.Compressed -> {
|
||||
val plainPath = FromChatLogDirs.tempShareFilePath("$stamp.log")
|
||||
PlatformFileSystem.writeBytes(plainPath, text.encodeToByteArray())
|
||||
val gzipPath = "$plainPath.gz"
|
||||
LogFileOps.gzipFile(plainPath, gzipPath)
|
||||
gzipPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compression == LogShareCompression.Compressed && sourcePath != null -> sourcePath
|
||||
|
||||
sourcePath != null && sourcePath.endsWith(".gz") -> {
|
||||
val path = FromChatLogDirs.tempShareFilePath("$stamp.log")
|
||||
LogFileOps.gunzipToFile(sourcePath, path, onProgress)
|
||||
path
|
||||
}
|
||||
|
||||
sourcePath != null -> {
|
||||
val path = FromChatLogDirs.tempShareFilePath("$stamp.log")
|
||||
val bytes = LogFileOps.readText(sourcePath).encodeToByteArray()
|
||||
PlatformFileSystem.writeBytes(path, bytes)
|
||||
path
|
||||
}
|
||||
|
||||
else -> FromChatLogDirs.exportFilePath()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun wipeAllLogs() {
|
||||
val dir = FromChatLogDirs.logsDirectoryPath()
|
||||
PlatformFileSystem.listFileNamesInDirectory(dir).forEach { name ->
|
||||
PlatformFileSystem.delete("$dir/$name")
|
||||
}
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
|
||||
private fun cleanBySize(maxTotalBytes: Long) {
|
||||
val dir = FromChatLogDirs.logsDirectoryPath()
|
||||
if (maxTotalBytes <= 0L) {
|
||||
wipeAllLogs()
|
||||
return
|
||||
}
|
||||
|
||||
data class NamedSize(val path: String, val size: Long, val name: String)
|
||||
|
||||
val files = PlatformFileSystem.listFileNamesInDirectory(dir)
|
||||
.map { name -> NamedSize("$dir/$name", PlatformFileSystem.fileSize("$dir/$name"), name) }
|
||||
.sortedBy { it.name }
|
||||
|
||||
var total = files.sumOf { it.size }
|
||||
val current = files.firstOrNull { it.name == FromChatLogDirs.CURRENT_LOG_FILE }
|
||||
val archives = files.filter { it.name != FromChatLogDirs.CURRENT_LOG_FILE && it.name != FromChatLogDirs.EXPORT_FILE }
|
||||
|
||||
for (archive in archives) {
|
||||
if (total <= maxTotalBytes) break
|
||||
PlatformFileSystem.delete(archive.path)
|
||||
total -= archive.size
|
||||
}
|
||||
|
||||
current?.let { live ->
|
||||
if (total > maxTotalBytes && PlatformFileSystem.exists(live.path)) {
|
||||
val parsed = parseLogText(LogFileOps.readText(live.path))
|
||||
var kept = parsed
|
||||
while (kept.isNotEmpty() && total > maxTotalBytes) {
|
||||
kept = kept.drop(1)
|
||||
val rebuilt = kept.joinToString("\n") { it.formattedLine() }
|
||||
PlatformFileSystem.writeBytes(live.path, rebuilt.encodeToByteArray())
|
||||
total = archives.filter { PlatformFileSystem.exists(it.path) }.sumOf { file ->
|
||||
PlatformFileSystem.fileSize(file.path)
|
||||
} + PlatformFileSystem.fileSize(live.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanByEntries(keepNewestEntries: Int) {
|
||||
val currentPath = FromChatLogDirs.currentLogPath()
|
||||
if (!PlatformFileSystem.exists(currentPath)) return
|
||||
val kept = parseLogText(LogFileOps.readText(currentPath)).takeLast(keepNewestEntries)
|
||||
if (kept.isEmpty()) {
|
||||
PlatformFileSystem.delete(currentPath)
|
||||
} else {
|
||||
PlatformFileSystem.writeBytes(
|
||||
currentPath,
|
||||
kept.joinToString("\n") { it.formattedLine() }.encodeToByteArray(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanByDate(deleteBefore: LocalDate?) {
|
||||
if (deleteBefore == null) return
|
||||
val cutoffMs = deleteBefore.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
|
||||
|
||||
val dir = FromChatLogDirs.logsDirectoryPath()
|
||||
PlatformFileSystem.listFileNamesInDirectory(dir)
|
||||
.filter { it.endsWith(".gz") }
|
||||
.forEach { name ->
|
||||
val archiveDate = archiveDateFromName(name)
|
||||
if (archiveDate != null && archiveDate < deleteBefore) {
|
||||
PlatformFileSystem.delete("$dir/$name")
|
||||
}
|
||||
}
|
||||
|
||||
val currentPath = FromChatLogDirs.currentLogPath()
|
||||
if (!PlatformFileSystem.exists(currentPath)) return
|
||||
val kept = parseLogText(LogFileOps.readText(currentPath)).filter {
|
||||
it.timestamp.toEpochMilliseconds() >= cutoffMs
|
||||
}
|
||||
if (kept.isEmpty()) {
|
||||
PlatformFileSystem.delete(currentPath)
|
||||
} else {
|
||||
PlatformFileSystem.writeBytes(
|
||||
currentPath,
|
||||
kept.joinToString("\n") { it.formattedLine() }.encodeToByteArray(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshEntriesLocked() {
|
||||
val text = runCatching { LogFileOps.readText(FromChatLogDirs.currentLogPath()) }.getOrDefault("")
|
||||
setEntriesFromParsed(parseLogText(text))
|
||||
loadedFromDisk = true
|
||||
}
|
||||
|
||||
private fun setEntriesFromParsed(parsed: List<AppLogEntry>) {
|
||||
_entries.value = parsed.takeLast(MAX_MEMORY_ENTRIES)
|
||||
nextEntryId = (_entries.value.maxOfOrNull { it.id } ?: -1L) + 1L
|
||||
}
|
||||
|
||||
private fun buildExportTextLocked(): String = buildString {
|
||||
val dir = FromChatLogDirs.logsDirectoryPath()
|
||||
PlatformFileSystem.listFileNamesInDirectory(dir)
|
||||
.filter { it.endsWith(".gz") }
|
||||
.sorted()
|
||||
.forEach { name ->
|
||||
appendLine("===== $name (gzip archive) =====")
|
||||
}
|
||||
appendLine("===== ${FromChatLogDirs.CURRENT_LOG_FILE} =====")
|
||||
append(runCatching { LogFileOps.readText(FromChatLogDirs.currentLogPath()) }.getOrDefault(""))
|
||||
}
|
||||
|
||||
internal fun parseLogText(text: String): List<AppLogEntry> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val result = mutableListOf<AppLogEntry>()
|
||||
var current: AppLogEntry? = null
|
||||
val traceLines = StringBuilder()
|
||||
var nextId = 0L
|
||||
|
||||
fun flushTrace() {
|
||||
val entry = current ?: return
|
||||
if (traceLines.isNotEmpty()) {
|
||||
current = entry.copy(stackTrace = traceLines.toString().trimEnd())
|
||||
traceLines.clear()
|
||||
}
|
||||
}
|
||||
|
||||
text.lineSequence().forEach { rawLine ->
|
||||
if (rawLine.startsWith(CONTINUATION_PREFIX) && current != null) {
|
||||
if (traceLines.isNotEmpty()) traceLines.append('\n')
|
||||
traceLines.append(rawLine.removePrefix(CONTINUATION_PREFIX))
|
||||
return@forEach
|
||||
}
|
||||
flushTrace()
|
||||
current?.let { result += it }
|
||||
current = parsePrimaryLine(rawLine, nextId++)
|
||||
traceLines.clear()
|
||||
}
|
||||
flushTrace()
|
||||
current?.let { result += it }
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parsePrimaryLine(line: String, id: Long): AppLogEntry? {
|
||||
if (line.isBlank()) return null
|
||||
|
||||
parseFormattedPrimaryLine(line, id)?.let { return it }
|
||||
|
||||
val parts = line.split('\t', limit = 4)
|
||||
if (parts.size < 4) return null
|
||||
val timestamp = runCatching { Instant.parse(parts[0]) }.getOrNull() ?: return null
|
||||
val level = AppLogLevel.fromLetter(parts[1].firstOrNull() ?: return null) ?: return null
|
||||
return AppLogEntry(
|
||||
id = id,
|
||||
timestamp = timestamp,
|
||||
level = level,
|
||||
tag = parts[2],
|
||||
message = parts[3],
|
||||
)
|
||||
}
|
||||
|
||||
private fun archiveDateFromName(name: String): LocalDate? {
|
||||
val body = name.removePrefix("log-").removeSuffix(".log.gz")
|
||||
val datePart = body.takeWhile { it != 'T' && it != ' ' }
|
||||
return runCatching { LocalDate.parse(datePart) }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||
|
||||
/** App-wide diagnostic logs under `cacheDir/fromchat/logs/` (not per-instance). */
|
||||
object FromChatLogDirs {
|
||||
private const val LOGS_SUBDIR = "fromchat/logs"
|
||||
const val CURRENT_LOG_FILE = "current.log"
|
||||
const val EXPORT_FILE = "export.txt"
|
||||
|
||||
fun logsDirectoryPath(): String =
|
||||
PlatformFileSystem.ensureDirectory(
|
||||
"${PlatformFileSystem.getAppCacheDirectory()}/$LOGS_SUBDIR",
|
||||
)
|
||||
|
||||
fun currentLogPath(): String = "${logsDirectoryPath()}/$CURRENT_LOG_FILE"
|
||||
|
||||
fun exportFilePath(): String = "${logsDirectoryPath()}/$EXPORT_FILE"
|
||||
|
||||
fun tempShareFilePath(suffix: String): String = "${logsDirectoryPath()}/share-$suffix"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
internal expect fun gzipCompress(input: ByteArray): ByteArray
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
internal expect object LogFileOps {
|
||||
fun readText(path: String): String
|
||||
|
||||
fun readBytes(path: String): ByteArray
|
||||
|
||||
suspend fun gzipFile(sourcePath: String, destinationPath: String)
|
||||
|
||||
suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String
|
||||
|
||||
suspend fun gunzipToFile(sourcePath: String, destinationPath: String, onProgress: (Float) -> Unit)
|
||||
|
||||
suspend fun zipFiles(entries: List<Pair<String, String>>, destinationPath: String)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
expect object LogShare {
|
||||
fun shareText(title: String, text: String)
|
||||
|
||||
fun shareFile(title: String, filePath: String, mimeType: String = "text/plain")
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
internal data class ZipFileEntry(
|
||||
val name: String,
|
||||
val data: ByteArray,
|
||||
)
|
||||
|
||||
internal fun buildStoreZipArchive(entries: List<ZipFileEntry>): ByteArray {
|
||||
if (entries.isEmpty()) return ByteArray(0)
|
||||
|
||||
val localParts = mutableListOf<ByteArray>()
|
||||
val centralParts = mutableListOf<ByteArray>()
|
||||
var offset = 0
|
||||
|
||||
entries.forEach { entry ->
|
||||
val nameBytes = entry.name.encodeToByteArray()
|
||||
val crc = crc32(entry.data)
|
||||
val localHeader = buildLocalFileHeader(
|
||||
nameBytes = nameBytes,
|
||||
crc = crc,
|
||||
compressedSize = entry.data.size,
|
||||
uncompressedSize = entry.data.size,
|
||||
)
|
||||
localParts += localHeader
|
||||
localParts += entry.data
|
||||
|
||||
centralParts += buildCentralDirectoryHeader(
|
||||
nameBytes = nameBytes,
|
||||
crc = crc,
|
||||
compressedSize = entry.data.size,
|
||||
uncompressedSize = entry.data.size,
|
||||
localHeaderOffset = offset,
|
||||
)
|
||||
offset += localHeader.size + entry.data.size
|
||||
}
|
||||
|
||||
val centralDirectory = centralParts.fold(ByteArray(0)) { acc, part -> acc + part }
|
||||
val endRecord = buildEndOfCentralDirectory(
|
||||
entryCount = entries.size,
|
||||
centralDirectorySize = centralDirectory.size,
|
||||
centralDirectoryOffset = offset,
|
||||
)
|
||||
return localParts.fold(ByteArray(0)) { acc, part -> acc + part } + centralDirectory + endRecord
|
||||
}
|
||||
|
||||
private fun buildLocalFileHeader(
|
||||
nameBytes: ByteArray,
|
||||
crc: UInt,
|
||||
compressedSize: Int,
|
||||
uncompressedSize: Int,
|
||||
): ByteArray = buildZipRecord(30 + nameBytes.size) {
|
||||
writeUInt16(0x0403) // version needed
|
||||
writeUInt16(0) // general purpose bit flag
|
||||
writeUInt16(0) // compression method: stored
|
||||
writeUInt16(0) // last mod file time
|
||||
writeUInt16(0) // last mod file date
|
||||
writeUInt32(crc.toLong())
|
||||
writeUInt32(compressedSize.toLong())
|
||||
writeUInt32(uncompressedSize.toLong())
|
||||
writeUInt16(nameBytes.size)
|
||||
writeUInt16(0) // extra length
|
||||
writeBytes(nameBytes)
|
||||
}
|
||||
|
||||
private fun buildCentralDirectoryHeader(
|
||||
nameBytes: ByteArray,
|
||||
crc: UInt,
|
||||
compressedSize: Int,
|
||||
uncompressedSize: Int,
|
||||
localHeaderOffset: Int,
|
||||
): ByteArray = buildZipRecord(46 + nameBytes.size) {
|
||||
writeUInt16(0x0314) // version made by
|
||||
writeUInt16(0x0403) // version needed
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt32(crc.toLong())
|
||||
writeUInt32(compressedSize.toLong())
|
||||
writeUInt32(uncompressedSize.toLong())
|
||||
writeUInt16(nameBytes.size)
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt32(localHeaderOffset.toLong())
|
||||
writeBytes(nameBytes)
|
||||
}
|
||||
|
||||
private fun buildEndOfCentralDirectory(
|
||||
entryCount: Int,
|
||||
centralDirectorySize: Int,
|
||||
centralDirectoryOffset: Int,
|
||||
): ByteArray = buildZipRecord(22) {
|
||||
writeUInt16(0)
|
||||
writeUInt16(0)
|
||||
writeUInt16(entryCount)
|
||||
writeUInt16(entryCount)
|
||||
writeUInt32(centralDirectorySize.toLong())
|
||||
writeUInt32(centralDirectoryOffset.toLong())
|
||||
writeUInt16(0)
|
||||
}
|
||||
|
||||
private class ZipBufferBuilder(val bytes: ByteArray) {
|
||||
var index = 0
|
||||
private set
|
||||
|
||||
fun writeUInt16(value: Int) {
|
||||
bytes[index++] = (value and 0xFF).toByte()
|
||||
bytes[index++] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
fun writeUInt32(value: Long) {
|
||||
bytes[index++] = (value and 0xFF).toByte()
|
||||
bytes[index++] = ((value shr 8) and 0xFF).toByte()
|
||||
bytes[index++] = ((value shr 16) and 0xFF).toByte()
|
||||
bytes[index++] = ((value shr 24) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
fun writeBytes(data: ByteArray) {
|
||||
data.copyInto(bytes, index)
|
||||
index += data.size
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun buildZipRecord(size: Int, block: ZipBufferBuilder.() -> Unit): ByteArray {
|
||||
val builder = ZipBufferBuilder(ByteArray(size))
|
||||
builder.block()
|
||||
check(builder.index == size) { "ZIP record size mismatch: expected $size, wrote ${builder.index}" }
|
||||
return builder.bytes
|
||||
}
|
||||
|
||||
private fun crc32(data: ByteArray): UInt {
|
||||
var crc = 0xFFFF_FFFFu
|
||||
for (byte in data) {
|
||||
crc = crc xor byte.toUInt()
|
||||
repeat(8) {
|
||||
crc = if (crc and 1u != 0u) {
|
||||
(crc shr 1) xor 0xEDB8_8320u
|
||||
} else {
|
||||
crc shr 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc.inv()
|
||||
}
|
||||
@@ -86,6 +86,9 @@ import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
||||
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
|
||||
import ru.fromchat.ui.main.settings.LogFilesScreen
|
||||
import ru.fromchat.ui.main.settings.LogsScreen
|
||||
import ru.fromchat.ui.main.settings.AboutScreen
|
||||
import ru.fromchat.ui.main.settings.AppearanceScreen
|
||||
import ru.fromchat.ui.main.settings.DevicesScreen
|
||||
@@ -224,6 +227,7 @@ fun App(
|
||||
runCatching { ensureFromChatCacheGeneration() }
|
||||
runCatching { NetworkConnectivity.ensureStarted() }
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
Logger.i("App", "FromChat started")
|
||||
}
|
||||
|
||||
val hasToken = ApiClient.token?.isNotEmpty() == true
|
||||
@@ -313,7 +317,7 @@ fun App(
|
||||
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(profileLookupErrorMessage) {
|
||||
profileLookupErrorMessage?.let { message ->
|
||||
Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
|
||||
Logger.w("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
|
||||
profileLookupSnackbarHostState.showSnackbar(
|
||||
message = message,
|
||||
withDismissAction = true,
|
||||
@@ -593,6 +597,21 @@ fun App(
|
||||
AboutScreen()
|
||||
}
|
||||
|
||||
settingsComposable(SettingsRoutes.Logs) {
|
||||
LogsScreen()
|
||||
}
|
||||
|
||||
settingsComposable(SettingsRoutes.LogFiles) {
|
||||
LogFilesScreen(
|
||||
onOpenFile = { file ->
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set(LOG_FILE_OPEN_RESULT_KEY, file.path)
|
||||
navController.navigateUp()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
settingsComposable(
|
||||
route = DocumentType.ROUTE,
|
||||
arguments = listOf(
|
||||
@@ -660,7 +679,7 @@ fun App(
|
||||
|
||||
DisposableEffect(navController) {
|
||||
ApiClient.onAuthError = {
|
||||
Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||
Logger.i("App", "Global auth error handler triggered, navigating to login")
|
||||
runCatching {
|
||||
navController.navigateAndWipeBackStack("welcome")
|
||||
}.onFailure { e ->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -10,10 +11,20 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -24,11 +35,15 @@ import coil3.compose.AsyncImage
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.about
|
||||
import ru.fromchat.auth_get_started
|
||||
import ru.fromchat.auth_welcome_tagline
|
||||
import ru.fromchat.auth_welcome_title
|
||||
import ru.fromchat.logs_title
|
||||
import ru.fromchat.more
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@Composable
|
||||
@@ -45,46 +60,83 @@ fun WelcomeScreen(
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
val navController = LocalNavController.current
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Res.getUri("drawable/logo_square.svg"),
|
||||
contentDescription = null,
|
||||
IconButton(
|
||||
onClick = { menuExpanded = true },
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(Res.string.more),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.about)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
navController.navigate(SettingsRoutes.About)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.logs_title)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
navController.navigate(SettingsRoutes.Logs)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Res.getUri("drawable/logo_square.svg"),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_tagline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_tagline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
ActionButton(onClick = onGetStarted) {
|
||||
Text(stringResource(Res.string.auth_get_started))
|
||||
ActionButton(onClick = onGetStarted) {
|
||||
Text(stringResource(Res.string.auth_get_started))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||
Logger.w("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
package ru.fromchat.ui.main.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.HorizontalFloatingToolbar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.action_delete
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.cancel
|
||||
import ru.fromchat.confirm
|
||||
import ru.fromchat.cd_close_selection
|
||||
import ru.fromchat.logs_clear_all_cd
|
||||
import ru.fromchat.logs_clear_all_confirm_body
|
||||
import ru.fromchat.logs_clear_all_confirm_title
|
||||
import ru.fromchat.logs_delete_files_confirm_body
|
||||
import ru.fromchat.logs_delete_files_confirm_title
|
||||
import ru.fromchat.logs_file_size_kb
|
||||
import ru.fromchat.logs_file_size_mb
|
||||
import ru.fromchat.logs_files_title
|
||||
import ru.fromchat.logs_open
|
||||
import ru.fromchat.logs_rotate
|
||||
import ru.fromchat.logs_rotate_confirm_body
|
||||
import ru.fromchat.logs_rotate_confirm_title
|
||||
import ru.fromchat.logs_selected_count
|
||||
import ru.fromchat.logs_share
|
||||
import ru.fromchat.logs_title
|
||||
import ru.fromchat.logging.AppLogStore
|
||||
import ru.fromchat.logging.FromChatLogDirs
|
||||
import ru.fromchat.logging.LogCleanMode
|
||||
import ru.fromchat.logging.LogCleanRequest
|
||||
import ru.fromchat.logging.LogFileInfo
|
||||
import ru.fromchat.logging.LogShare
|
||||
import ru.fromchat.logging.LogShareCompression
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.BackHandler
|
||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
|
||||
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
|
||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
||||
|
||||
private enum class LogFilesListMode {
|
||||
Normal,
|
||||
Selecting,
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalMaterial3ExpressiveApi::class,
|
||||
ExperimentalFoundationApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun LogFilesScreen(
|
||||
onOpenFile: (LogFileInfo) -> Unit,
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptic = rememberHapticFeedback()
|
||||
val density = LocalDensity.current
|
||||
|
||||
var logFiles by remember { mutableStateOf<List<LogFileInfo>>(emptyList()) }
|
||||
val listState: LazyListState = rememberLazyListState()
|
||||
var listMode by remember { mutableStateOf(LogFilesListMode.Normal) }
|
||||
var selectedFilePaths by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val selectionTransitionProgress = remember { Animatable(0f) }
|
||||
var showClearAllConfirm by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showRotateConfirm by remember { mutableStateOf(false) }
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
var pendingSharePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var pendingShareIsCurrent by remember { mutableStateOf(false) }
|
||||
var deletingFilePaths by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val gestureState = rememberLogsListGestureState()
|
||||
var dragAnchorIndex by remember { mutableIntStateOf(-1) }
|
||||
var dragLastY by remember { mutableFloatStateOf(0f) }
|
||||
var listRootY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val shareTitle = stringResource(Res.string.logs_title)
|
||||
|
||||
val selectionMode = listMode == LogFilesListMode.Selecting
|
||||
val selectionProgress = selectionTransitionProgress.value
|
||||
val showClearFab = !selectionMode && selectionProgress <= 0f
|
||||
val canOpenSingleFile = selectedFilePaths.size == 1
|
||||
val canRotateCurrentLog = selectedFilePaths.size == 1 &&
|
||||
logFiles.any {
|
||||
it.path in selectedFilePaths && it.name == FromChatLogDirs.CURRENT_LOG_FILE
|
||||
}
|
||||
|
||||
val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedFilePaths.size)
|
||||
val closeSelectionCd = stringResource(Res.string.cd_close_selection)
|
||||
val openLabel = stringResource(Res.string.logs_open)
|
||||
val shareLabel = stringResource(Res.string.logs_share)
|
||||
val deleteLabel = stringResource(Res.string.action_delete)
|
||||
val rotateLabel = stringResource(Res.string.logs_rotate)
|
||||
val clearAllCd = stringResource(Res.string.logs_clear_all_cd)
|
||||
|
||||
fun refreshLogFiles() {
|
||||
logFiles = AppLogStore.listLogFiles()
|
||||
}
|
||||
|
||||
fun enterSelection(path: String) {
|
||||
haptic(HapticFeedbackEvent.SelectionModeEntered)
|
||||
scope.launch { selectionTransitionProgress.snapTo(0f) }
|
||||
listMode = LogFilesListMode.Selecting
|
||||
selectedFilePaths = setOf(path)
|
||||
}
|
||||
|
||||
fun exitSelection() {
|
||||
gestureState.reset()
|
||||
scope.launch { selectionTransitionProgress.snapTo(0f) }
|
||||
listMode = LogFilesListMode.Normal
|
||||
selectedFilePaths = emptySet()
|
||||
dragAnchorIndex = -1
|
||||
}
|
||||
|
||||
fun requestExitSelection() {
|
||||
scope.launch {
|
||||
selectionTransitionProgress.animateTo(0f, ChatSelectionTransitionSpring)
|
||||
exitSelection()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAllLogs() {
|
||||
val pathsToClear = logFiles.map { it.path }.toSet()
|
||||
if (pathsToClear.isEmpty()) {
|
||||
showClearAllConfirm = false
|
||||
return
|
||||
}
|
||||
deletingFilePaths = deletingFilePaths + pathsToClear
|
||||
scope.launch {
|
||||
AppLogStore.clean(LogCleanRequest(mode = LogCleanMode.All))
|
||||
showClearAllConfirm = false
|
||||
requestExitSelection()
|
||||
// Allow shrink animation to complete before refreshing the list.
|
||||
delay(220)
|
||||
deletingFilePaths = emptySet()
|
||||
refreshLogFiles()
|
||||
}
|
||||
}
|
||||
|
||||
fun performShare(compression: LogShareCompression) {
|
||||
if (pendingSharePaths.isEmpty()) return
|
||||
scope.launch {
|
||||
val path = if (pendingSharePaths.size > 1) {
|
||||
AppLogStore.prepareMultiFileShareZip(pendingSharePaths)
|
||||
} else {
|
||||
AppLogStore.prepareSharePath(
|
||||
sourcePath = pendingSharePaths.single(),
|
||||
isCurrentLog = pendingShareIsCurrent,
|
||||
compression = compression,
|
||||
)
|
||||
}
|
||||
val mimeType = when {
|
||||
pendingSharePaths.size > 1 -> "application/zip"
|
||||
compression == LogShareCompression.Compressed -> "application/gzip"
|
||||
path.endsWith(".gz") -> "application/gzip"
|
||||
else -> "text/plain"
|
||||
}
|
||||
LogShare.shareFile(shareTitle, path, mimeType)
|
||||
pendingSharePaths = emptyList()
|
||||
showShareSheet = false
|
||||
requestExitSelection()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelectedFiles() {
|
||||
val pathsToDelete = selectedFilePaths
|
||||
if (pathsToDelete.isEmpty()) {
|
||||
showDeleteConfirm = false
|
||||
return
|
||||
}
|
||||
deletingFilePaths = deletingFilePaths + pathsToDelete
|
||||
scope.launch {
|
||||
pathsToDelete.forEach { path ->
|
||||
AppLogStore.deleteLogFile(path)
|
||||
}
|
||||
showDeleteConfirm = false
|
||||
requestExitSelection()
|
||||
// Allow shrink animation to complete before refreshing the list.
|
||||
delay(220)
|
||||
deletingFilePaths = emptySet()
|
||||
refreshLogFiles()
|
||||
}
|
||||
}
|
||||
|
||||
fun applyDragSelectionRange(toIndex: Int) {
|
||||
val anchor = dragAnchorIndex
|
||||
if (anchor < 0 || toIndex < 0) return
|
||||
val start = minOf(anchor, toIndex)
|
||||
val end = maxOf(anchor, toIndex)
|
||||
selectedFilePaths = logFiles.subList(start, end + 1).map { it.path }.toSet()
|
||||
}
|
||||
|
||||
fun beginDragSelection(index: Int) {
|
||||
if (index !in logFiles.indices) return
|
||||
gestureState.onDragSelectionStart()
|
||||
dragAnchorIndex = index
|
||||
val path = logFiles[index].path
|
||||
if (!selectionMode) {
|
||||
enterSelection(path)
|
||||
} else {
|
||||
applyDragSelectionRange(index)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
refreshLogFiles()
|
||||
}
|
||||
|
||||
LaunchedEffect(listMode) {
|
||||
if (listMode == LogFilesListMode.Selecting) {
|
||||
selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedFilePaths, listMode) {
|
||||
if (listMode == LogFilesListMode.Selecting && selectedFilePaths.isEmpty()) {
|
||||
requestExitSelection()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(gestureState.dragSelectActive, listState) {
|
||||
if (!gestureState.dragSelectActive) return@LaunchedEffect
|
||||
val edgeThresholdPx = with(density) { 72.dp.toPx() }
|
||||
while (isActive && gestureState.dragSelectActive) {
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height.toFloat()
|
||||
when {
|
||||
dragLastY < edgeThresholdPx -> {
|
||||
listState.scrollBy(-18f)
|
||||
listState.logFileIndexAtY(dragLastY, logFiles.size)
|
||||
?.let { applyDragSelectionRange(it) }
|
||||
}
|
||||
dragLastY > viewportHeight - edgeThresholdPx -> {
|
||||
listState.scrollBy(18f)
|
||||
listState.logFileIndexAtY(dragLastY, logFiles.size)
|
||||
?.let { applyDragSelectionRange(it) }
|
||||
}
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { exitSelection() }
|
||||
}
|
||||
|
||||
if (showClearAllConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearAllConfirm = false },
|
||||
title = { Text(stringResource(Res.string.logs_clear_all_confirm_title)) },
|
||||
text = { Text(stringResource(Res.string.logs_clear_all_confirm_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { clearAllLogs() }) {
|
||||
Text(stringResource(Res.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearAllConfirm = false }) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
title = { Text(stringResource(Res.string.logs_delete_files_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.logs_delete_files_confirm_body,
|
||||
selectedFilePaths.size,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { deleteSelectedFiles() }) {
|
||||
Text(deleteLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showRotateConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRotateConfirm = false },
|
||||
title = { Text(stringResource(Res.string.logs_rotate_confirm_title)) },
|
||||
text = { Text(stringResource(Res.string.logs_rotate_confirm_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showRotateConfirm = false
|
||||
scope.launch {
|
||||
AppLogStore.rotate()
|
||||
refreshLogFiles()
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(Res.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRotateConfirm = false }) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showShareSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showShareSheet = false
|
||||
pendingSharePaths = emptyList()
|
||||
},
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
LogsShareBottomSheet(
|
||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||
onCompressed = { performShare(LogShareCompression.Compressed) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = selectionMode) { requestExitSelection() }
|
||||
PredictiveBackHandler(
|
||||
enabled = selectionMode,
|
||||
onProgress = { backProgress ->
|
||||
scope.launch {
|
||||
selectionTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f))
|
||||
}
|
||||
},
|
||||
onCommit = { requestExitSelection() },
|
||||
onCancel = {
|
||||
if (selectionMode) {
|
||||
scope.launch {
|
||||
selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val selectionBarVisible = selectionMode || selectionProgress > 0f
|
||||
val listBottomInset = if (selectionBarVisible) 88.dp else 8.dp
|
||||
val fileCategoryColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
floatingActionButton = {
|
||||
val fabReveal = (1f - selectionProgress).coerceIn(0f, 1f)
|
||||
LogsAnimatedFab(
|
||||
visible = showClearFab && fabReveal > 0f,
|
||||
alpha = fabReveal,
|
||||
onClick = { showClearAllConfirm = true },
|
||||
contentDescription = clearAllCd,
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
)
|
||||
},
|
||||
topBar = {
|
||||
Box {
|
||||
TopAppBar(
|
||||
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { navController.navigateUp() },
|
||||
enabled = selectionProgress < 1f,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.logs_files_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
if (selectionMode || selectionProgress > 0f) {
|
||||
TopAppBar(
|
||||
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { requestExitSelection() },
|
||||
enabled = selectionProgress > 0f,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = closeSelectionCd,
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = selectedCountTitle,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
DisableSelection {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { listRootY = it.positionInRoot().y },
|
||||
contentPadding = PaddingValues(bottom = listBottomInset),
|
||||
) {
|
||||
Category(
|
||||
margin = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
containerColor = fileCategoryColor,
|
||||
) {
|
||||
logFiles.forEachIndexed { index, file ->
|
||||
val isSelected = file.path in selectedFilePaths
|
||||
item {
|
||||
AnimatedVisibility(
|
||||
visible = file.path !in deletingFilePaths,
|
||||
enter = fadeIn(ChatSelectionTransitionSpring),
|
||||
exit = fadeOut(ChatSelectionTransitionSpring) + shrinkVertically(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
shrinkTowards = Alignment.Top,
|
||||
),
|
||||
) {
|
||||
LogFileRow(
|
||||
file = file,
|
||||
fileSelectionMode = selectionMode,
|
||||
fileSelectionProgress = selectionProgress,
|
||||
isSelected = isSelected,
|
||||
divider = index < logFiles.lastIndex,
|
||||
onTap = {
|
||||
if (gestureState.shouldSuppressTap()) return@LogFileRow
|
||||
if (selectionMode) {
|
||||
selectedFilePaths = if (file.path in selectedFilePaths) {
|
||||
selectedFilePaths - file.path
|
||||
} else {
|
||||
selectedFilePaths + file.path
|
||||
}
|
||||
} else {
|
||||
onOpenFile(file)
|
||||
}
|
||||
},
|
||||
gestureState = gestureState,
|
||||
getListRootY = { listRootY },
|
||||
onBeginDragSelection = { beginDragSelection(index) },
|
||||
onDragAtListLocalY = { listLocalY ->
|
||||
dragLastY = listLocalY
|
||||
listState.logFileIndexAtY(listLocalY, logFiles.size)
|
||||
?.let { applyDragSelectionRange(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = selectionBarVisible,
|
||||
enter = slideInVertically(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
initialOffsetY = { fullHeight -> fullHeight },
|
||||
),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
targetOffsetY = { fullHeight -> fullHeight },
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
HorizontalFloatingToolbar(
|
||||
expanded = true,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val selected = logFiles.filter { it.path in selectedFilePaths }
|
||||
if (selected.size == 1) onOpenFile(selected.first())
|
||||
},
|
||||
enabled = canOpenSingleFile,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, openLabel)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { showRotateConfirm = true },
|
||||
enabled = canRotateCurrentLog,
|
||||
) {
|
||||
Icon(Icons.Default.Sync, rotateLabel)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
val selected = logFiles.filter { it.path in selectedFilePaths }
|
||||
if (selected.isEmpty()) return@IconButton
|
||||
pendingSharePaths = selected.map { it.path }
|
||||
pendingShareIsCurrent = selected.all {
|
||||
it.name == FromChatLogDirs.CURRENT_LOG_FILE
|
||||
}
|
||||
showShareSheet = true
|
||||
},
|
||||
enabled = selectedFilePaths.isNotEmpty(),
|
||||
) {
|
||||
Icon(Icons.Default.Share, shareLabel)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (selectedFilePaths.isEmpty()) return@IconButton
|
||||
showDeleteConfirm = true
|
||||
},
|
||||
enabled = selectedFilePaths.isNotEmpty(),
|
||||
) {
|
||||
Icon(Icons.Rounded.Delete, deleteLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogFileRow(
|
||||
file: LogFileInfo,
|
||||
fileSelectionMode: Boolean,
|
||||
fileSelectionProgress: Float,
|
||||
isSelected: Boolean,
|
||||
divider: Boolean,
|
||||
onTap: () -> Unit,
|
||||
gestureState: LogsListGestureState,
|
||||
getListRootY: () -> Float,
|
||||
onBeginDragSelection: () -> Unit,
|
||||
onDragAtListLocalY: (Float) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val rowRootYHolder = remember { LogsRowRootYHolder() }
|
||||
|
||||
val tintProgress = if (isSelected) fileSelectionProgress.coerceIn(0f, 1f) else 0f
|
||||
val colors = logsSelectionColors(
|
||||
isSelected = isSelected,
|
||||
selectionProgress = tintProgress,
|
||||
baseContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { rowRootYHolder.y = it.positionInRoot().y }
|
||||
.logsRowDragSelectGestures(
|
||||
gestureState = gestureState,
|
||||
scope = scope,
|
||||
rowRootYHolder = rowRootYHolder,
|
||||
getListRootY = getListRootY,
|
||||
onDragStart = onBeginDragSelection,
|
||||
onDragAtListLocalY = onDragAtListLocalY,
|
||||
),
|
||||
headline = file.name,
|
||||
supportingText = formatLogFileSize(file.sizeBytes),
|
||||
containerColor = colors.containerColor,
|
||||
onClick = {
|
||||
if (!gestureState.shouldSuppressTap()) {
|
||||
onTap()
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SelectionCheckmarkSlot(
|
||||
selectionTransitionProgress = fileSelectionProgress,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
Icon(
|
||||
imageVector = when {
|
||||
file.name == FromChatLogDirs.CURRENT_LOG_FILE -> Icons.Default.History
|
||||
file.isGzip -> Icons.Default.Archive
|
||||
else -> Icons.AutoMirrored.Rounded.InsertDriveFile
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = colors.iconColor,
|
||||
)
|
||||
}
|
||||
},
|
||||
divider = divider,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatLogFileSize(sizeBytes: Long): String {
|
||||
val kb = (sizeBytes / 1024).toInt()
|
||||
if (sizeBytes < 1024 * 1024) {
|
||||
return stringResource(Res.string.logs_file_size_kb, kb)
|
||||
}
|
||||
val megabytes = "%.1f".format(sizeBytes / (1024f * 1024f))
|
||||
return stringResource(Res.string.logs_file_size_mb, megabytes)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
package ru.fromchat.ui.main.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
internal fun LazyListState.indexAtY(y: Float): Int? {
|
||||
for (item in layoutInfo.visibleItemsInfo) {
|
||||
val top = item.offset.toFloat()
|
||||
val bottom = top + item.size
|
||||
if (y in top..bottom) return item.index
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun LazyListState.indexAtRootY(rootY: Float, listRootY: Float): Int? =
|
||||
indexAtY(rootY - listRootY)
|
||||
|
||||
/** Maps a [LazyListState.indexAtY] result to a file index inside [LazyListScope.Category]. */
|
||||
internal fun LazyListState.logFileIndexAtY(y: Float, fileCount: Int): Int? {
|
||||
if (fileCount == 0) return null
|
||||
val lazyIndex = indexAtY(y) ?: return null
|
||||
val fileIndex = lazyIndex - 1
|
||||
return fileIndex.takeIf { it in 0 until fileCount }
|
||||
}
|
||||
|
||||
internal fun LazyListState.logFileIndexAtRootY(rootY: Float, listRootY: Float, fileCount: Int): Int? =
|
||||
logFileIndexAtY(rootY - listRootY, fileCount)
|
||||
|
||||
@Stable
|
||||
internal class LogsListGestureState {
|
||||
var dragSelectActive by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var suppressTapUntilMark: TimeSource.Monotonic.ValueTimeMark? = null
|
||||
|
||||
fun onDragSelectionStart() {
|
||||
dragSelectActive = true
|
||||
suppressTapUntilMark = TimeSource.Monotonic.markNow() + TapSuppressDuration
|
||||
}
|
||||
|
||||
fun onDragSelectionEnd(scope: CoroutineScope) {
|
||||
dragSelectActive = false
|
||||
suppressTapUntilMark = TimeSource.Monotonic.markNow() + TapSuppressDuration
|
||||
val mark = suppressTapUntilMark
|
||||
scope.launch {
|
||||
delay(TapSuppressDuration)
|
||||
if (suppressTapUntilMark == mark) {
|
||||
suppressTapUntilMark = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
dragSelectActive = false
|
||||
suppressTapUntilMark = null
|
||||
}
|
||||
|
||||
fun shouldSuppressTap(): Boolean =
|
||||
dragSelectActive || suppressTapUntilMark?.hasNotPassedNow() == true
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberLogsListGestureState(): LogsListGestureState =
|
||||
remember { LogsListGestureState() }
|
||||
|
||||
@Composable
|
||||
internal fun LogsToolbarActionSlot(
|
||||
visible: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandHorizontally(
|
||||
animationSpec = LogsToolbarSpaceSpring,
|
||||
expandFrom = Alignment.Start,
|
||||
) + fadeIn(ChatSelectionTransitionSpring),
|
||||
exit = shrinkHorizontally(
|
||||
animationSpec = LogsToolbarSpaceSpring,
|
||||
shrinkTowards = Alignment.Start,
|
||||
) + fadeOut(ChatSelectionTransitionSpring),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class LogsSelectionColors(
|
||||
val containerColor: Color,
|
||||
val bodyColor: Color,
|
||||
val mutedColor: Color,
|
||||
val iconColor: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun logsSelectionColors(
|
||||
isSelected: Boolean,
|
||||
selectionProgress: Float,
|
||||
baseContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
baseBodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
baseMutedColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
baseIconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
): LogsSelectionColors {
|
||||
val selectedContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||
val selectedContentColor = MaterialTheme.colorScheme.primary
|
||||
val tintProgress = if (isSelected) selectionProgress.coerceIn(0f, 1f) else 0f
|
||||
|
||||
return if (tintProgress > 0f) {
|
||||
LogsSelectionColors(
|
||||
containerColor = lerp(baseContainerColor, selectedContainerColor, tintProgress),
|
||||
bodyColor = lerp(baseBodyColor, selectedContentColor, tintProgress),
|
||||
mutedColor = lerp(baseMutedColor, selectedContentColor, tintProgress),
|
||||
iconColor = lerp(baseIconColor, selectedContentColor, tintProgress),
|
||||
)
|
||||
} else {
|
||||
LogsSelectionColors(
|
||||
containerColor = baseContainerColor,
|
||||
bodyColor = baseBodyColor,
|
||||
mutedColor = baseMutedColor,
|
||||
iconColor = baseIconColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TapSuppressDuration = 250.milliseconds
|
||||
|
||||
internal val LogsToolbarSpaceSpring: FiniteAnimationSpec<IntSize> = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
)
|
||||
|
||||
internal fun LazyListState.isScrolledToEnd(): Boolean {
|
||||
val info = layoutInfo
|
||||
if (info.totalItemsCount == 0) return true
|
||||
val lastItem = info.visibleItemsInfo.lastOrNull() ?: return false
|
||||
if (lastItem.index != info.totalItemsCount - 1) return false
|
||||
return lastItem.offset + lastItem.size <= info.viewportEndOffset + 4
|
||||
}
|
||||
|
||||
/** Updated synchronously in [onGloballyPositioned]; safe to read from gesture callbacks. */
|
||||
internal class LogsRowRootYHolder(var y: Float = 0f)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun Modifier.logsRowDragSelectGestures(
|
||||
gestureState: LogsListGestureState,
|
||||
scope: CoroutineScope,
|
||||
rowRootYHolder: LogsRowRootYHolder,
|
||||
getListRootY: () -> Float,
|
||||
onDragStart: () -> Unit,
|
||||
onDragAtListLocalY: (listLocalY: Float) -> Unit,
|
||||
): Modifier = pointerInput(gestureState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { onDragStart() },
|
||||
onDrag = { change, _ ->
|
||||
val listLocalY = rowRootYHolder.y + change.position.y - getListRootY()
|
||||
onDragAtListLocalY(listLocalY)
|
||||
change.consume()
|
||||
},
|
||||
onDragEnd = { gestureState.onDragSelectionEnd(scope) },
|
||||
onDragCancel = { gestureState.onDragSelectionEnd(scope) },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun Modifier.logsDragSelectGestures(
|
||||
enabled: Boolean,
|
||||
gestureState: LogsListGestureState,
|
||||
scope: CoroutineScope,
|
||||
onDragStart: (viewportY: Float) -> Unit,
|
||||
onDrag: (viewportY: Float) -> Unit,
|
||||
): Modifier {
|
||||
if (!enabled) return this
|
||||
return pointerInput(gestureState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset -> onDragStart(offset.y) },
|
||||
onDrag = { change, _ ->
|
||||
onDrag(change.position.y)
|
||||
change.consume()
|
||||
},
|
||||
onDragEnd = { gestureState.onDragSelectionEnd(scope) },
|
||||
onDragCancel = { gestureState.onDragSelectionEnd(scope) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,6 @@ object SettingsRoutes {
|
||||
const val AccountDeleteFlow = "settings/account/delete"
|
||||
const val ServerConfig = "serverConfig"
|
||||
const val About = "about"
|
||||
const val Logs = "settings/logs"
|
||||
const val LogFiles = "settings/logs/files"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -40,7 +41,9 @@ import ru.fromchat.settings_category_devices
|
||||
import ru.fromchat.settings_category_devices_d
|
||||
import ru.fromchat.settings_category_notifications
|
||||
import ru.fromchat.settings_category_notifications_d
|
||||
import ru.fromchat.logs_title
|
||||
import ru.fromchat.settings_hub_about_sub
|
||||
import ru.fromchat.settings_hub_logs_sub
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
|
||||
val SettingsStepHorizontalPadding = 24.dp
|
||||
@@ -113,7 +116,15 @@ fun SettingsTab() {
|
||||
headline = stringResource(Res.string.about),
|
||||
supportingText = stringResource(Res.string.settings_hub_about_sub),
|
||||
onClick = { navController.navigate(SettingsRoutes.About) },
|
||||
leadingContent = { Icon(Icons.Filled.Info, null) }
|
||||
leadingContent = { Icon(Icons.Filled.Info, null) },
|
||||
divider = true
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.logs_title),
|
||||
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
||||
onClick = { navController.navigate(SettingsRoutes.Logs) },
|
||||
leadingContent = { Icon(Icons.Outlined.BugReport, null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -316,18 +318,18 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
while (isActive) {
|
||||
if (!WebSocketManager.isConnected) {
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
var loadedSuccessfully = false
|
||||
|
||||
suspend fun attemptLoad(): Boolean {
|
||||
if (loadedSuccessfully || !currentCoroutineContext().isActive) return loadedSuccessfully
|
||||
if (!WebSocketManager.isConnected) return false
|
||||
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
|
||||
)
|
||||
|
||||
try {
|
||||
return try {
|
||||
val profile = when {
|
||||
targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile()
|
||||
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
|
||||
@@ -348,6 +350,7 @@ fun ProfileScreen(
|
||||
isLoading = false,
|
||||
error = ProfileLoadError.Generic
|
||||
)
|
||||
false
|
||||
} else {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
@@ -359,6 +362,8 @@ fun ProfileScreen(
|
||||
|
||||
ProfileCache.put(profile)
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
loadedSuccessfully = true
|
||||
true
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
val fallback = resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
||||
@@ -415,9 +420,20 @@ fun ProfileScreen(
|
||||
profile = resolvedProfile,
|
||||
isLoading = false
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
if (attemptLoad()) return@LaunchedEffect
|
||||
|
||||
val onReconnect: suspend () -> Unit = {
|
||||
attemptLoad()
|
||||
}
|
||||
WebSocketManager.addSessionReadyHandler(onReconnect)
|
||||
try {
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
WebSocketManager.removeSessionReadyHandler(onReconnect)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
package ru.fromchat
|
||||
|
||||
import platform.Foundation.NSLog
|
||||
import ru.fromchat.logging.AppLogLevel
|
||||
import ru.fromchat.logging.AppLogStore
|
||||
|
||||
actual object Logger {
|
||||
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Debug, tag, message, throwable)
|
||||
NSLog("DEBUG: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Info, tag, message, throwable)
|
||||
NSLog("INFO: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Warn, tag, message, throwable)
|
||||
NSLog("WARN: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Error, tag, message, throwable)
|
||||
NSLog("ERROR: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun f(tag: String, message: String, throwable: Throwable?) {
|
||||
AppLogStore.record(AppLogLevel.Fatal, tag, message, throwable)
|
||||
NSLog("FATAL: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.UByteVar
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.sizeOf
|
||||
import kotlinx.cinterop.toCValues
|
||||
import platform.zlib.Z_DEFAULT_COMPRESSION
|
||||
import platform.zlib.compress2
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal actual fun gzipCompress(input: ByteArray): ByteArray {
|
||||
if (input.isEmpty()) {
|
||||
return byteArrayOf(0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
val header = byteArrayOf(
|
||||
0x1f,
|
||||
0x8b.toByte(),
|
||||
0x08,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x03,
|
||||
)
|
||||
|
||||
val deflated = deflateRaw(input)
|
||||
val crc = crc32(input)
|
||||
val isize = input.size
|
||||
|
||||
val footer = ByteArray(8)
|
||||
footer[0] = (crc and 0xFF).toByte()
|
||||
footer[1] = ((crc shr 8) and 0xFF).toByte()
|
||||
footer[2] = ((crc shr 16) and 0xFF).toByte()
|
||||
footer[3] = ((crc shr 24) and 0xFF).toByte()
|
||||
footer[4] = (isize and 0xFF).toByte()
|
||||
footer[5] = ((isize shr 8) and 0xFF).toByte()
|
||||
footer[6] = ((isize shr 16) and 0xFF).toByte()
|
||||
footer[7] = ((isize shr 24) and 0xFF).toByte()
|
||||
|
||||
return header + deflated + footer
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun deflateRaw(input: ByteArray): ByteArray = memScoped {
|
||||
if (input.isEmpty()) return@memScoped ByteArray(0)
|
||||
|
||||
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
|
||||
while (true) {
|
||||
val output = allocArray<UByteVar>(capacity)
|
||||
val source = input.toUByteArray().toCValues()
|
||||
val sourceLength = input.size.convert<platform.zlib.uLong>()
|
||||
val destLength = alloc<platform.zlib.uLongVar>()
|
||||
destLength.value = capacity.convert()
|
||||
|
||||
val status = compress2(
|
||||
output,
|
||||
destLength.ptr,
|
||||
source.ptr.reinterpret(),
|
||||
sourceLength,
|
||||
Z_DEFAULT_COMPRESSION,
|
||||
)
|
||||
|
||||
if (status == 0) {
|
||||
val size = destLength.value.toInt()
|
||||
return@memScoped ByteArray(size) { index -> output[index].toByte() }
|
||||
}
|
||||
|
||||
capacity *= 2
|
||||
if (capacity > input.size * 20) {
|
||||
return@memScoped input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toUByteArray(): UByteArray = UByteArray(size) { this[it].toUByte() }
|
||||
|
||||
private fun crc32(data: ByteArray): Int {
|
||||
var crc = 0xFFFFFFFF.toInt()
|
||||
for (byte in data) {
|
||||
crc = crc xor (byte.toInt() and 0xFF)
|
||||
repeat(8) {
|
||||
crc = if (crc and 1 != 0) {
|
||||
0xEDB88320.toInt() xor (crc ushr 1)
|
||||
} else {
|
||||
crc ushr 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc.inv()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.UByteVar
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.toCValues
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSString
|
||||
import platform.Foundation.NSUTF8StringEncoding
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.writeToFile
|
||||
import platform.posix.memcpy
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal actual object LogFileOps {
|
||||
actual fun readText(path: String): String {
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ""
|
||||
return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as? String
|
||||
?: ""
|
||||
}
|
||||
|
||||
actual fun readBytes(path: String): ByteArray {
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ByteArray(0)
|
||||
val raw = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
||||
return raw.toByteArray()
|
||||
}
|
||||
|
||||
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.Default) {
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
|
||||
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
|
||||
val bytes = raw.toByteArray()
|
||||
val gzipped = gzipCompress(bytes)
|
||||
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||
NSData.create(bytes = gzipped, length = gzipped.size.toULong())
|
||||
.writeToFile(destinationPath, true)
|
||||
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
|
||||
}
|
||||
|
||||
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
|
||||
withContext(Dispatchers.Default) {
|
||||
val bytes = gunzipToByteArray(path, onProgress)
|
||||
bytes.decodeToString()
|
||||
}
|
||||
|
||||
actual suspend fun gunzipToFile(
|
||||
sourcePath: String,
|
||||
destinationPath: String,
|
||||
onProgress: (Float) -> Unit,
|
||||
) = withContext(Dispatchers.Default) {
|
||||
val bytes = gunzipToByteArray(sourcePath, onProgress)
|
||||
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||
NSData.create(bytes = bytes, length = bytes.size.toULong())
|
||||
.writeToFile(destinationPath, true)
|
||||
}
|
||||
|
||||
actual suspend fun zipFiles(
|
||||
entries: List<Pair<String, String>>,
|
||||
destinationPath: String,
|
||||
) = withContext(Dispatchers.Default) {
|
||||
if (entries.isEmpty()) return@withContext
|
||||
val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
|
||||
ZipFileEntry(entryName, readBytes(sourcePath))
|
||||
}
|
||||
val bytes = buildStoreZipArchive(zipEntries)
|
||||
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||
NSData.create(bytes = bytes, length = bytes.size.toULong())
|
||||
.writeToFile(destinationPath, true)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
|
||||
onProgress(1f)
|
||||
return ByteArray(0)
|
||||
}
|
||||
val raw = NSData.dataWithContentsOfFile(path) ?: run {
|
||||
onProgress(1f)
|
||||
return ByteArray(0)
|
||||
}
|
||||
val compressed = raw.toByteArray()
|
||||
if (compressed.size < 18) {
|
||||
onProgress(1f)
|
||||
return ByteArray(0)
|
||||
}
|
||||
val deflated = compressed.copyOfRange(10, compressed.size - 8)
|
||||
val inflated = inflateGzipPayload(deflated)
|
||||
onProgress(1f)
|
||||
return inflated
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
|
||||
if (deflated.isEmpty()) return@memScoped ByteArray(0)
|
||||
|
||||
var capacity = (deflated.size * 4).coerceAtLeast(256)
|
||||
while (capacity <= deflated.size * 32) {
|
||||
val output = allocArray<UByteVar>(capacity)
|
||||
val destLength = alloc<platform.zlib.uLongVar>()
|
||||
destLength.value = capacity.convert<platform.zlib.uLong>()
|
||||
val source = deflated.toUByteArray().toCValues()
|
||||
val status = platform.zlib.uncompress(
|
||||
output,
|
||||
destLength.ptr,
|
||||
source.ptr.reinterpret(),
|
||||
deflated.size.convert(),
|
||||
)
|
||||
if (status == 0) {
|
||||
val size = destLength.value.toInt()
|
||||
return@memScoped ByteArray(size) { index -> output[index].toByte() }
|
||||
}
|
||||
capacity *= 2
|
||||
}
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
val length = this.length.toInt()
|
||||
if (length == 0) return ByteArray(0)
|
||||
val bytes = ByteArray(length)
|
||||
bytes.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), this.bytes, this.length)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun ByteArray.toUByteArray(): UByteArray = UByteArray(size) { this[it].toUByte() }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.fromchat.logging
|
||||
|
||||
import platform.Foundation.NSURL
|
||||
import platform.UIKit.UIActivityViewController
|
||||
import platform.UIKit.UIApplication
|
||||
|
||||
actual object LogShare {
|
||||
actual fun shareText(title: String, text: String) {
|
||||
val controller = UIActivityViewController(
|
||||
activityItems = listOf(text),
|
||||
applicationActivities = null,
|
||||
)
|
||||
present(controller)
|
||||
}
|
||||
|
||||
actual fun shareFile(title: String, filePath: String, mimeType: String) {
|
||||
val url = NSURL.fileURLWithPath(filePath)
|
||||
val controller = UIActivityViewController(
|
||||
activityItems = listOf(url),
|
||||
applicationActivities = null,
|
||||
)
|
||||
present(controller)
|
||||
}
|
||||
|
||||
private fun present(controller: UIActivityViewController) {
|
||||
val root = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return
|
||||
root.presentViewController(controller, animated = true, completion = null)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user