From 9b4aa50e7cc07753b65ccf25343f9c5091ae9cc3 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 6 Jul 2026 18:28:12 +0300 Subject: [PATCH] Add logs screen --- .cursor/skills/adapt-to-style/SKILL.md | 11 +- .../main/kotlin/ru/fromchat/MainActivity.kt | 5 +- .../fcm/FromChatFirebaseMessagingService.kt | 28 +- .../notifications/NotificationHelper.kt | 58 +- .../NotificationReplyReceiver.kt | 18 +- .../kotlin/ru/fromchat/Logger.android.kt | 11 + .../fromchat/api/FcmRegistration.android.kt | 23 +- .../download/DmFileDownloader.android.kt | 2 +- .../fromchat/logging/GzipCompress.android.kt | 10 + .../ru/fromchat/logging/LogFileOps.android.kt | 96 ++ .../ru/fromchat/logging/LogShare.android.kt | 40 + .../ui/calls/CallMediaLayer.android.kt | 8 +- .../composeResources/values-ru/strings.xml | 50 + .../composeResources/values/strings.xml | 50 + .../commonMain/kotlin/ru/fromchat/Logger.kt | 1 + .../ru/fromchat/api/UpdateSyncManager.kt | 4 +- .../kotlin/ru/fromchat/api/calls/CallStore.kt | 8 +- .../ru/fromchat/api/local/WebSocketManager.kt | 10 +- .../kotlin/ru/fromchat/logging/AppLogEntry.kt | 95 ++ .../kotlin/ru/fromchat/logging/AppLogLevel.kt | 33 + .../kotlin/ru/fromchat/logging/AppLogStore.kt | 408 +++++ .../ru/fromchat/logging/FromChatLogDirs.kt | 21 + .../ru/fromchat/logging/GzipCompress.kt | 3 + .../kotlin/ru/fromchat/logging/LogFileOps.kt | 15 + .../kotlin/ru/fromchat/logging/LogShare.kt | 7 + .../kotlin/ru/fromchat/logging/ZipArchive.kt | 146 ++ .../commonMain/kotlin/ru/fromchat/ui/App.kt | 23 +- .../kotlin/ru/fromchat/ui/WelcomeScreen.kt | 112 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 2 +- .../ui/main/settings/LogFilesScreen.kt | 702 ++++++++ .../fromchat/ui/main/settings/LogsScreen.kt | 1464 +++++++++++++++++ .../ui/main/settings/LogsSelectionUtils.kt | 210 +++ .../ui/main/settings/SettingsRoutes.kt | 2 + .../fromchat/ui/main/settings/SettingsTab.kt | 13 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 30 +- .../iosMain/kotlin/ru/fromchat/Logger.ios.kt | 11 + .../ru/fromchat/logging/GzipCompress.ios.kt | 99 ++ .../ru/fromchat/logging/LogFileOps.ios.kt | 143 ++ .../ru/fromchat/logging/LogShare.ios.kt | 29 + 39 files changed, 3876 insertions(+), 125 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/logging/GzipCompress.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogFileOps.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogShare.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogEntry.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogLevel.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/FromChatLogDirs.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/GzipCompress.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogFileOps.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogShare.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/logging/ZipArchive.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsSelectionUtils.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogShare.ios.kt diff --git a/.cursor/skills/adapt-to-style/SKILL.md b/.cursor/skills/adapt-to-style/SKILL.md index 25231eb..2b0d7b3 100644 --- a/.cursor/skills/adapt-to-style/SKILL.md +++ b/.cursor/skills/adapt-to-style/SKILL.md @@ -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_.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. + diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt index 0fa3bb3..50ffa93 100644 --- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt +++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt @@ -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) } } } diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt index 587ac5c..ca41052 100644 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt +++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt @@ -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) } } } - - diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index ae2525a..7fcdff8 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -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() .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() .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) { - 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}") } } } diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt index e2a5208..54476c5 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt @@ -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) } } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/Logger.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/Logger.android.kt index 856666a..c415d8b 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/Logger.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/Logger.android.kt @@ -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) + } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt index 800e1ea..1c33819 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt @@ -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() - 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 } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.android.kt index 7ec111b..2b35e64 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.android.kt @@ -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) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/logging/GzipCompress.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/logging/GzipCompress.android.kt new file mode 100644 index 0000000..40f1f06 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/logging/GzipCompress.android.kt @@ -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() +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogFileOps.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogFileOps.android.kt new file mode 100644 index 0000000..f1148d1 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogFileOps.android.kt @@ -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>, + 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() + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogShare.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogShare.android.kt new file mode 100644 index 0000000..73f611b --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/logging/LogShare.android.kt @@ -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)) + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt index 0f2ae83..a0b055b 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt @@ -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 -> {} } diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 3ae7830..144d610 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -333,6 +333,56 @@ Введите новый пароль ещё раз, чтобы убедиться, что без ошибок. Далее Версия, ссылки и другое + Журнал + Просмотр, отправка и очистка журнала + Записей пока нет + Поделиться журналом + Ротация журнала + Ротировать файл журнала? + Текущий журнал будет заархивирован и начнётся новый пустой файл. + Очистить журнал + Скопировано в буфер обмена + Очистка журнала + Очистить + По общему размеру + Удалить всё + По числу записей + До даты + Удаляет старые архивы и записи, пока общий размер журнала не станет меньше лимита. + Лимит: %1$d МБ + Удаляет текущий файл журнала и все архивы. + Оставляет только самые новые записи в текущем файле. + Оставить новых: %1$d + Удаляет записи и архивы до выбранной даты (ваш часовой пояс). + Год + Месяц + День + Как отправить журнал? + Без сжатия + Проще читать без дополнительных программ + Сжатый + Меньший размер, но нужен gzip для просмотра + Распаковка… + Файлы журнала + Просмотр файлов журнала + Выбрано: %1$d + verbose + debug + info + warning + error + fatal + Открыть + Удалить файл журнала? + Файл будет безвозвратно удалён с устройства. + Удалить выбранные файлы журнала? + Будет безвозвратно удалено файлов: %1$d. + Очистить все файлы логов + Очистить все файлы логов? + Будут удалены текущий лог и все архивы. + %1$d КБ + %1$s МБ + Прокрутить к последним записям Аккаунт Выйти? diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index ea3174e..c8291d4 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -359,6 +359,56 @@ Type your new password again to make sure it matches. Next Version, links, and more + Logs + View, share, and manage app logs + No log entries yet + Share logs + Rotate logs + Rotate log file? + The current log will be archived and a new empty log file will be started. + Clean logs + Copied to clipboard + Clean logs + Clean + By total size + Delete everything + By entry count + Before date + Delete oldest archives and entries until total log storage is below the limit. + Limit: %1$d MB + Deletes the current log file and all rotated archives. + Keep only the newest entries in the current log file. + Keep newest: %1$d + Delete entries and archives before the selected date (your time zone). + Year + Month + Day + How do you want to send the logs? + Uncompressed + Easier to read without any additional software + Compressed + Smaller file size, but requires gzip to view + Decompressing… + Log files + Browse log files + %1$d selected + verbose + debug + info + warning + error + fatal + Open + Delete log file? + This file will be permanently removed from the device. + Delete selected log files? + %1$d files will be permanently removed from the device. + Clear all log files + Clear all log files? + This deletes the current log and all rotated archives. + %1$d KB + %1$s MB + Scroll to latest logs Account Log out? diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt index 451b2f6..072f248 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt @@ -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) } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt index 3144765..b6424af 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt @@ -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}" ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt index 0c8635e..cf2d4c3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt @@ -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}", ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt index 4dc7cd8..138273d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt @@ -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() { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogEntry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogEntry.kt new file mode 100644 index 0000000..201eece --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogEntry.kt @@ -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) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogLevel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogLevel.kt new file mode 100644 index 0000000..a54692c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogLevel.kt @@ -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 + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt new file mode 100644 index 0000000..ade9925 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt @@ -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>(emptyList()) + val entries: StateFlow> = _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 { + 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 = 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 = 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? = 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) { + _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 { + if (text.isBlank()) return emptyList() + val result = mutableListOf() + 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() + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/FromChatLogDirs.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/FromChatLogDirs.kt new file mode 100644 index 0000000..d7e2c88 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/FromChatLogDirs.kt @@ -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" +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/GzipCompress.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/GzipCompress.kt new file mode 100644 index 0000000..a312041 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/GzipCompress.kt @@ -0,0 +1,3 @@ +package ru.fromchat.logging + +internal expect fun gzipCompress(input: ByteArray): ByteArray diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogFileOps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogFileOps.kt new file mode 100644 index 0000000..05d0d59 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogFileOps.kt @@ -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>, destinationPath: String) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogShare.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogShare.kt new file mode 100644 index 0000000..73f7e73 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/LogShare.kt @@ -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") +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/ZipArchive.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/ZipArchive.kt new file mode 100644 index 0000000..6f3522d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/ZipArchive.kt @@ -0,0 +1,146 @@ +package ru.fromchat.logging + +internal data class ZipFileEntry( + val name: String, + val data: ByteArray, +) + +internal fun buildStoreZipArchive(entries: List): ByteArray { + if (entries.isEmpty()) return ByteArray(0) + + val localParts = mutableListOf() + val centralParts = mutableListOf() + 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() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index c445695..a32f4a7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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 -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt index 0de5ebb..acc3e10 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt @@ -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)) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index bdebaaf..3b1dbd0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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}") } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt new file mode 100644 index 0000000..16497ab --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt @@ -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>(emptyList()) } + val listState: LazyListState = rememberLazyListState() + var listMode by remember { mutableStateOf(LogFilesListMode.Normal) } + var selectedFilePaths by remember { mutableStateOf>(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>(emptyList()) } + var pendingShareIsCurrent by remember { mutableStateOf(false) } + var deletingFilePaths by remember { mutableStateOf>(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) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt new file mode 100644 index 0000000..63ee86e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt @@ -0,0 +1,1464 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Compress +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.automirrored.filled.Subject +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.utils.supportClipboardManagerImpl +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.action_copy +import ru.fromchat.action_delete +import ru.fromchat.back +import ru.fromchat.cancel +import ru.fromchat.cd_close_selection +import ru.fromchat.confirm +import ru.fromchat.logs_browse_files_cd +import ru.fromchat.logs_clean +import ru.fromchat.logs_clean_all_body +import ru.fromchat.logs_clean_apply +import ru.fromchat.logs_clean_date_body +import ru.fromchat.logs_clean_entries_body +import ru.fromchat.logs_clean_entries_count +import ru.fromchat.logs_clean_mode_all +import ru.fromchat.logs_clean_mode_date +import ru.fromchat.logs_clean_mode_entries +import ru.fromchat.logs_clean_mode_size +import ru.fromchat.logs_clean_size_body +import ru.fromchat.logs_clean_size_mb +import ru.fromchat.logs_clean_title +import ru.fromchat.logs_decompressing +import ru.fromchat.logs_delete_file_confirm_body +import ru.fromchat.logs_delete_file_confirm_title +import ru.fromchat.logs_empty +import ru.fromchat.logs_level_debug +import ru.fromchat.logs_level_error +import ru.fromchat.logs_level_fatal +import ru.fromchat.logs_level_info +import ru.fromchat.logs_level_verbose +import ru.fromchat.logs_level_warn +import ru.fromchat.logs_rotate +import ru.fromchat.logs_rotate_confirm_body +import ru.fromchat.logs_rotate_confirm_title +import ru.fromchat.logs_scroll_to_bottom_cd +import ru.fromchat.logs_selected_count +import ru.fromchat.logs_share +import ru.fromchat.logs_share_compressed +import ru.fromchat.logs_share_compressed_desc +import ru.fromchat.logs_share_how_title +import ru.fromchat.logs_share_uncompressed +import ru.fromchat.logs_share_uncompressed_desc +import ru.fromchat.logs_title +import ru.fromchat.more +import ru.fromchat.logging.AppLogEntry +import ru.fromchat.logging.AppLogLevel +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.logging.formatLogTimestamp +import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.components.BackHandler +import ru.fromchat.ui.components.ExpressiveIconFrame +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 +import androidx.compose.ui.unit.dp + +import kotlin.time.Instant + +private val LogsSheetEnterTween = tween(durationMillis = 180, easing = FastOutSlowInEasing) +private val LogsSheetExitTween = tween(durationMillis = 120, easing = FastOutLinearInEasing) + +private enum class LogsListMode { + Normal, + Selecting, +} + +internal data class LogsShareRequest( + val sourcePath: String?, + val isCurrentLog: Boolean, + val entries: List? = null, +) + +internal const val LOG_FILE_OPEN_RESULT_KEY = "logFileToOpen" + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalFoundationApi::class, +) +@Composable +fun LogsScreen() { + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val clipboard = supportClipboardManagerImpl + val haptic = rememberHapticFeedback() + val density = LocalDensity.current + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) + val listState = rememberLazyListState() + + val liveEntries by AppLogStore.entries.collectAsState() + var viewedEntries by remember { mutableStateOf?>(null) } + var viewingFilePath by remember { mutableStateOf(null) } + + val displayEntries = viewedEntries ?: liveEntries + val displayFileName = viewingFilePath?.substringAfterLast('/') + ?: FromChatLogDirs.CURRENT_LOG_FILE + val isViewingCurrent = viewingFilePath == null + + var menuExpanded by remember { mutableStateOf(false) } + var showCleanSheet by remember { mutableStateOf(false) } + var showShareSheet by remember { mutableStateOf(false) } + var showDecompressDialog by remember { mutableStateOf(false) } + var showDeleteFileConfirm by remember { mutableStateOf(false) } + var showRotateConfirm by remember { mutableStateOf(false) } + var pendingDeletePaths by remember { mutableStateOf>(emptySet()) } + + var cleanMode by remember { mutableStateOf(LogCleanMode.Size) } + var sizeLimitMb by remember { mutableIntStateOf(10) } + var keepEntries by remember { mutableIntStateOf(1_000) } + var cleanBeforeDate by remember { mutableStateOf(null) } + + var listMode by remember { mutableStateOf(LogsListMode.Normal) } + var selectedEntryIds by remember { mutableStateOf>(emptySet()) } + val selectionTransitionProgress = remember { Animatable(0f) } + val gestureState = rememberLogsListGestureState() + var dragAnchorIndex by remember { mutableIntStateOf(-1) } + var dragLastY by remember { mutableFloatStateOf(0f) } + var listRootY by remember { mutableFloatStateOf(0f) } + val followLatestState = remember { mutableStateOf(true) } + var followLatest by followLatestState + var isAtBottom by remember { mutableStateOf(true) } + var isProgrammaticScroll by remember { mutableStateOf(false) } + + val disableFollowOnUserScroll = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source == NestedScrollSource.UserInput) { + followLatestState.value = false + } + return Offset.Zero + } + } + } + + var decompressProgress by remember { mutableFloatStateOf(0f) } + var pendingShareRequest by remember { mutableStateOf(null) } + + val shareTitle = stringResource(Res.string.logs_title) + val selectionMode = listMode == LogsListMode.Selecting + val selectionProgress = selectionTransitionProgress.value + val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode + val showScrollToBottomFab = isViewingCurrent && + !selectionMode && + displayEntries.isNotEmpty() && + !isAtBottom && + !isProgrammaticScroll + val scrollToBottomCd = stringResource(Res.string.logs_scroll_to_bottom_cd) + + val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedEntryIds.size) + val closeSelectionCd = stringResource(Res.string.cd_close_selection) + val copyLabel = stringResource(Res.string.action_copy) + val shareLabel = stringResource(Res.string.logs_share) + val deleteLabel = stringResource(Res.string.action_delete) + + fun scrollToLatestLogs() { + if (displayEntries.isEmpty()) return + scope.launch { + isProgrammaticScroll = true + listState.animateScrollToItem(displayEntries.lastIndex) + isProgrammaticScroll = false + followLatest = listState.isScrolledToEnd() + } + } + + fun openLogFile(file: LogFileInfo) { + scope.launch { + if (file.isGzip) { + decompressProgress = 0f + showDecompressDialog = true + val parsed = AppLogStore.loadEntriesFromPath(file.path) { decompressProgress = it } + showDecompressDialog = false + viewingFilePath = file.path + viewedEntries = parsed + } else { + viewingFilePath = if (file.name == FromChatLogDirs.CURRENT_LOG_FILE) null else file.path + viewedEntries = if (file.name == FromChatLogDirs.CURRENT_LOG_FILE) { + null + } else { + AppLogStore.loadEntriesFromPath(file.path) + } + } + } + } + + fun enterEntrySelection(entryId: Long) { + haptic(HapticFeedbackEvent.SelectionModeEntered) + scope.launch { selectionTransitionProgress.snapTo(0f) } + listMode = LogsListMode.Selecting + selectedEntryIds = setOf(entryId) + } + + fun exitEntrySelection() { + gestureState.reset() + scope.launch { selectionTransitionProgress.snapTo(0f) } + listMode = LogsListMode.Normal + selectedEntryIds = emptySet() + dragAnchorIndex = -1 + } + + fun requestExitEntrySelection() { + scope.launch { + selectionTransitionProgress.animateTo(0f, ChatSelectionTransitionSpring) + exitEntrySelection() + } + } + + fun performShare(compression: LogShareCompression) { + val request = pendingShareRequest ?: return + scope.launch { + val path = AppLogStore.prepareSharePath( + sourcePath = request.sourcePath, + isCurrentLog = request.isCurrentLog, + compression = compression, + entries = request.entries, + ) + val mimeType = when { + compression == LogShareCompression.Compressed -> "application/gzip" + path.endsWith(".gz") -> "application/gzip" + else -> "text/plain" + } + LogShare.shareFile(shareTitle, path, mimeType) + pendingShareRequest = null + showShareSheet = false + if (selectionMode) requestExitEntrySelection() + } + } + + fun applyDragSelectionRange(toIndex: Int) { + val anchor = dragAnchorIndex + if (anchor < 0 || toIndex < 0) return + val start = minOf(anchor, toIndex) + val end = maxOf(anchor, toIndex) + selectedEntryIds = displayEntries.subList(start, end + 1).map { it.id }.toSet() + } + + fun beginDragSelection(index: Int) { + if (index !in displayEntries.indices) return + gestureState.onDragSelectionStart() + dragAnchorIndex = index + if (!selectionMode) { + enterEntrySelection(displayEntries[index].id) + } else { + applyDragSelectionRange(index) + } + } + + fun handleEntryTap(entry: AppLogEntry) { + if (gestureState.shouldSuppressTap()) return + if (!selectionMode) { + scope.launch { clipboard.setText(entry.displayText()) } + return + } + val wasSelected = entry.id in selectedEntryIds + selectedEntryIds = if (wasSelected) { + selectedEntryIds - entry.id + } else { + selectedEntryIds + entry.id + } + } + + fun resetToCurrentLog() { + viewingFilePath = null + viewedEntries = null + } + + LaunchedEffect(Unit) { + AppLogStore.ensureLoaded() + } + + LaunchedEffect(navController.currentBackStackEntry) { + navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow(LOG_FILE_OPEN_RESULT_KEY, null) + ?.collect { path -> + if (path == null) return@collect + val file = AppLogStore.listLogFiles().firstOrNull { it.path == path } ?: return@collect + openLogFile(file) + navController.currentBackStackEntry?.savedStateHandle?.remove(LOG_FILE_OPEN_RESULT_KEY) + } + } + + LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode) { + if ( + isViewingCurrent && + displayEntries.isNotEmpty() && + !selectionMode && + followLatest + ) { + isProgrammaticScroll = true + listState.scrollToItem(displayEntries.lastIndex) + isProgrammaticScroll = false + } + } + + 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.indexAtY(dragLastY)?.let { applyDragSelectionRange(it) } + } + dragLastY > viewportHeight - edgeThresholdPx -> { + listState.scrollBy(18f) + listState.indexAtY(dragLastY)?.let { applyDragSelectionRange(it) } + } + } + delay(16) + } + } + + LaunchedEffect(listState) { + snapshotFlow { + listState.isScrolledToEnd() to listState.isScrollInProgress + }.collect { (atBottom, inProgress) -> + isAtBottom = atBottom + if (!isProgrammaticScroll && atBottom && !inProgress) { + followLatest = true + } + if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + if (topAppBarState.overlappedFraction < 1f) { + topAppBarState.contentOffset = topAppBarState.heightOffsetLimit + } + } + } + } + + LaunchedEffect(selectionMode) { + if (selectionMode) { + followLatest = false + } else if (isAtBottom) { + followLatest = true + } + } + + LaunchedEffect(listMode) { + if (listMode == LogsListMode.Selecting) { + selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } + } + + LaunchedEffect(selectedEntryIds, listMode) { + if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) { + requestExitEntrySelection() + } + } + + DisposableEffect(Unit) { + onDispose { exitEntrySelection() } + } + + if (showDecompressDialog) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(Res.string.logs_decompressing)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + LinearProgressIndicator( + progress = { decompressProgress }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = {}, + ) + } + + if (showDeleteFileConfirm) { + AlertDialog( + onDismissRequest = { showDeleteFileConfirm = false }, + title = { Text(stringResource(Res.string.logs_delete_file_confirm_title)) }, + text = { Text(stringResource(Res.string.logs_delete_file_confirm_body)) }, + confirmButton = { + TextButton(onClick = { + scope.launch { + pendingDeletePaths.forEach { path -> + AppLogStore.deleteLogFile(path) + } + if (viewingFilePath in pendingDeletePaths) { + resetToCurrentLog() + } + showDeleteFileConfirm = false + pendingDeletePaths = emptySet() + } + }) { + Text(deleteLabel) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteFileConfirm = 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() + } + }) { + 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 + pendingShareRequest = null + }, + sheetState = sheetState, + ) { + LogsShareBottomSheet( + onUncompressed = { performShare(LogShareCompression.Uncompressed) }, + onCompressed = { performShare(LogShareCompression.Compressed) }, + ) + } + } + + if (showCleanSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showCleanSheet = false }, + sheetState = sheetState, + ) { + LogsCleanBottomSheet( + cleanMode = cleanMode, + onCleanModeChange = { cleanMode = it }, + sizeLimitMb = sizeLimitMb, + onSizeLimitMbChange = { sizeLimitMb = it }, + keepEntries = keepEntries, + onKeepEntriesChange = { keepEntries = it }, + cleanBeforeDate = cleanBeforeDate, + onCleanBeforeDateChange = { cleanBeforeDate = it }, + onDismiss = { showCleanSheet = false }, + onApply = { + scope.launch { + AppLogStore.clean( + LogCleanRequest( + mode = cleanMode, + maxTotalBytes = sizeLimitMb.toLong() * 1024L * 1024L, + keepNewestEntries = keepEntries, + deleteBefore = cleanBeforeDate, + ), + ) + if (!isViewingCurrent) resetToCurrentLog() + sheetState.hide() + showCleanSheet = false + } + }, + ) + } + } + + BackHandler(enabled = selectionMode) { requestExitEntrySelection() } + PredictiveBackHandler( + enabled = selectionMode, + onProgress = { backProgress -> + scope.launch { + selectionTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f)) + } + }, + onCommit = { requestExitEntrySelection() }, + onCancel = { + if (selectionMode) { + scope.launch { + selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } + } + }, + ) + + + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets.navigationBars, + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + val fabReveal = (1f - selectionProgress).coerceIn(0f, 1f) + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + LogsAnimatedFab( + visible = showScrollToBottomFab && fabReveal > 0f, + alpha = fabReveal, + onClick = { scrollToLatestLogs() }, + contentDescription = scrollToBottomCd, + icon = Icons.Default.KeyboardArrowDown, + small = true, + ) + LogsAnimatedFab( + visible = showBrowseFab && fabReveal > 0f, + alpha = fabReveal, + onClick = { navController.navigate(SettingsRoutes.LogFiles) }, + contentDescription = stringResource(Res.string.logs_browse_files_cd), + icon = Icons.Default.Folder, + ) + } + }, + 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 = { + Column { + Text(stringResource(Res.string.logs_title)) + Text( + text = displayFileName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + actions = { + if (isViewingCurrent && !selectionMode) { + IconButton( + onClick = { + pendingShareRequest = LogsShareRequest( + sourcePath = FromChatLogDirs.currentLogPath(), + isCurrentLog = true, + ) + showShareSheet = true + }, + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = shareLabel, + ) + } + } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(Res.string.more), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + if (isViewingCurrent) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.logs_rotate)) }, + leadingIcon = { + Icon(Icons.Default.Sync, contentDescription = null) + }, + onClick = { + menuExpanded = false + showRotateConfirm = true + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.logs_clean)) }, + leadingIcon = { + Icon(Icons.Default.CleaningServices, contentDescription = null) + }, + onClick = { + menuExpanded = false + showCleanSheet = true + }, + ) + } else { + DropdownMenuItem( + text = { Text(deleteLabel) }, + leadingIcon = { + Icon(Icons.Default.Delete, contentDescription = null) + }, + onClick = { + menuExpanded = false + viewingFilePath?.let { path -> + pendingDeletePaths = setOf(path) + showDeleteFileConfirm = true + } + }, + ) + } + } + } + }, + scrollBehavior = scrollBehavior, + ) + if (selectionMode || selectionProgress > 0f) { + TopAppBar( + modifier = Modifier.graphicsLayer { alpha = selectionProgress }, + navigationIcon = { + IconButton( + onClick = { requestExitEntrySelection() }, + enabled = selectionProgress > 0f, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = closeSelectionCd, + ) + } + }, + title = { + Text( + text = selectedCountTitle, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + actions = { + IconButton( + onClick = { + scope.launch { + val text = displayEntries + .filter { it.id in selectedEntryIds } + .joinToString("\n") { it.displayText() } + clipboard.setText(text) + requestExitEntrySelection() + } + }, + enabled = selectionProgress > 0f && selectedEntryIds.isNotEmpty(), + ) { + Icon(Icons.Default.ContentCopy, contentDescription = copyLabel) + } + IconButton( + onClick = { + pendingShareRequest = LogsShareRequest( + sourcePath = viewingFilePath, + isCurrentLog = isViewingCurrent, + entries = displayEntries.filter { it.id in selectedEntryIds }, + ) + showShareSheet = true + }, + enabled = selectionProgress > 0f && selectedEntryIds.isNotEmpty(), + ) { + Icon(Icons.Default.Share, contentDescription = shareLabel) + } + }, + ) + } + } + }, + ) { innerPadding -> + if (displayEntries.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.logs_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + val listContent: @Composable () -> Unit = { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .nestedScroll(disableFollowOnUserScroll) + .onGloballyPositioned { listRootY = it.positionInRoot().y }, + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed( + items = displayEntries, + key = { _, entry -> entry.id }, + ) { index, entry -> + LogEntryRow( + entry = entry, + selectionMode = selectionMode, + selectionProgress = selectionProgress, + isSelected = entry.id in selectedEntryIds, + gestureState = gestureState, + getListRootY = { listRootY }, + onBeginDragSelection = { beginDragSelection(index) }, + onDragAtListLocalY = { listLocalY -> + dragLastY = listLocalY + listState.indexAtY(listLocalY)?.let { applyDragSelectionRange(it) } + }, + onRowTap = { handleEntryTap(entry) }, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + listContent() + } + } + } +} + +@Composable +internal fun LogsAnimatedFab( + visible: Boolean, + alpha: Float, + onClick: () -> Unit, + contentDescription: String, + icon: ImageVector, + small: Boolean = false, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(ChatSelectionTransitionSpring) + slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialOffsetY = { fullHeight -> fullHeight }, + ), + exit = fadeOut(ChatSelectionTransitionSpring) + slideOutVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetOffsetY = { fullHeight -> fullHeight }, + ), + ) { + if (small) { + SmallFloatingActionButton( + modifier = Modifier.graphicsLayer { this.alpha = alpha }, + onClick = onClick, + ) { + Icon(imageVector = icon, contentDescription = contentDescription) + } + } else { + FloatingActionButton( + modifier = Modifier.graphicsLayer { this.alpha = alpha }, + onClick = onClick, + ) { + Icon(imageVector = icon, contentDescription = contentDescription) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun LogsShareBottomSheet( + onUncompressed: () -> Unit, + onCompressed: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.logs_share_how_title), + style = MaterialTheme.typography.titleLarge, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + onClick = onUncompressed, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.InsertDriveFile, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = stringResource(Res.string.logs_share_uncompressed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = stringResource(Res.string.logs_share_uncompressed_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + Surface( + onClick = onCompressed, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.Compress, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = stringResource(Res.string.logs_share_compressed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = stringResource(Res.string.logs_share_compressed_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun LogsCleanBottomSheet( + cleanMode: LogCleanMode, + onCleanModeChange: (LogCleanMode) -> Unit, + sizeLimitMb: Int, + onSizeLimitMbChange: (Int) -> Unit, + keepEntries: Int, + onKeepEntriesChange: (Int) -> Unit, + cleanBeforeDate: LocalDate?, + onCleanBeforeDateChange: (LocalDate?) -> Unit, + onDismiss: () -> Unit, + onApply: () -> Unit, +) { + val scrollState = rememberScrollState() + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = cleanBeforeDate?.let { date -> + date.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() + }, + ) + + LaunchedEffect(datePickerState.selectedDateMillis) { + val millis = datePickerState.selectedDateMillis ?: return@LaunchedEffect + onCleanBeforeDateChange( + Instant.fromEpochMilliseconds(millis) + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .navigationBarsPadding() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ExpressiveIconFrame( + icon = Icons.Default.CleaningServices, + materialPolygon = MaterialShapes.Cookie7Sided, + containerSize = 88.dp, + iconSize = 40.dp, + ) + + Text( + text = stringResource(Res.string.logs_clean_title), + style = MaterialTheme.typography.headlineSmall, + ) + + Category( + margin = PaddingValues(horizontal = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + ListItem( + headline = stringResource(Res.string.logs_clean_mode_size), + trailingContent = { + RadioButton( + selected = cleanMode == LogCleanMode.Size, + onClick = null, + ) + }, + onClick = { onCleanModeChange(LogCleanMode.Size) }, + divider = true, + ) + ListItem( + headline = stringResource(Res.string.logs_clean_mode_all), + trailingContent = { + RadioButton( + selected = cleanMode == LogCleanMode.All, + onClick = null, + ) + }, + onClick = { onCleanModeChange(LogCleanMode.All) }, + divider = true, + ) + ListItem( + headline = stringResource(Res.string.logs_clean_mode_entries), + trailingContent = { + RadioButton( + selected = cleanMode == LogCleanMode.Entries, + onClick = null, + ) + }, + onClick = { onCleanModeChange(LogCleanMode.Entries) }, + divider = true, + ) + ListItem( + headline = stringResource(Res.string.logs_clean_mode_date), + trailingContent = { + RadioButton( + selected = cleanMode == LogCleanMode.Date, + onClick = null, + ) + }, + onClick = { onCleanModeChange(LogCleanMode.Date) }, + divider = false, + ) + } + + AnimatedContent( + targetState = cleanMode, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + ), + transitionSpec = { + fadeIn(LogsSheetEnterTween) + slideInVertically( + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + initialOffsetY = { height -> height / 8 }, + ) togetherWith fadeOut(LogsSheetExitTween) + slideOutVertically( + animationSpec = tween(durationMillis = 120, easing = FastOutLinearInEasing), + targetOffsetY = { height -> -height / 8 }, + ) + }, + label = "logs_clean_mode_body", + ) { mode -> + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + when (mode) { + LogCleanMode.Size -> { + Text( + text = stringResource(Res.string.logs_clean_size_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.logs_clean_size_mb, sizeLimitMb), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sizeLimitMb.toFloat(), + onValueChange = { onSizeLimitMbChange(it.toInt().coerceIn(1, 100)) }, + valueRange = 1f..100f, + ) + } + + LogCleanMode.All -> { + Text( + text = stringResource(Res.string.logs_clean_all_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + LogCleanMode.Entries -> { + Text( + text = stringResource(Res.string.logs_clean_entries_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.logs_clean_entries_count, keepEntries), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = keepEntries.toFloat(), + onValueChange = { onKeepEntriesChange(it.toInt().coerceIn(100, 10_000)) }, + valueRange = 100f..10_000f, + ) + } + + LogCleanMode.Date -> { + Text( + text = stringResource(Res.string.logs_clean_date_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + DatePicker(state = datePickerState) + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.cancel)) + } + FilledTonalButton(onClick = onApply) { + Text(stringResource(Res.string.logs_clean_apply)) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LogEntryRow( + entry: AppLogEntry, + selectionMode: Boolean, + selectionProgress: Float, + isSelected: Boolean, + gestureState: LogsListGestureState, + getListRootY: () -> Float, + onBeginDragSelection: () -> Unit, + onDragAtListLocalY: (Float) -> Unit, + onRowTap: () -> Unit, +) { + val scope = rememberCoroutineScope() + val rowInteractionSource = remember { MutableInteractionSource() } + val rowRootYHolder = remember { LogsRowRootYHolder() } + + val tintProgress = if (isSelected) selectionProgress.coerceIn(0f, 1f) else 0f + val colors = logsSelectionColors( + isSelected = isSelected, + selectionProgress = tintProgress, + baseContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) + + val bodyStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + color = colors.bodyColor, + ) + val mutedStyle = bodyStyle.copy(color = colors.mutedColor) + + Surface( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { rowRootYHolder.y = it.positionInRoot().y } + .clickable( + interactionSource = rowInteractionSource, + indication = null, + onClick = { + if (!gestureState.shouldSuppressTap()) { + onRowTap() + } + }, + ) + .logsRowDragSelectGestures( + gestureState = gestureState, + scope = scope, + rowRootYHolder = rowRootYHolder, + getListRootY = getListRootY, + onDragStart = onBeginDragSelection, + onDragAtListLocalY = onDragAtListLocalY, + ) + .clip(MaterialTheme.shapes.medium), + shape = MaterialTheme.shapes.medium, + color = colors.containerColor, + ) { + LogEntryContent( + entry = entry, + bodyStyle = bodyStyle, + mutedStyle = mutedStyle, + levelChipContentColor = colors.mutedColor, + isSelected = isSelected, + selectionProgress = selectionProgress, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LogEntryMetadata( + entry: AppLogEntry, + mutedStyle: androidx.compose.ui.text.TextStyle, + levelChipContentColor: Color, + selectionProgress: Float, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + SelectionCheckmarkSlot( + selectionTransitionProgress = selectionProgress, + isSelected = isSelected, + ) + FlowRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = formatLogTimestamp(entry.timestamp), + style = mutedStyle, + modifier = Modifier.align(Alignment.CenterVertically), + ) + LogLevelChip( + level = entry.level, + contentColorOverride = levelChipContentColor, + modifier = Modifier.align(Alignment.CenterVertically), + ) + Text( + text = entry.tag, + style = MaterialTheme.typography.labelLarge, + color = mutedStyle.color, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + } +} + +@Composable +private fun LogEntryMessageBody( + entry: AppLogEntry, + bodyStyle: androidx.compose.ui.text.TextStyle, + mutedStyle: androidx.compose.ui.text.TextStyle, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = entry.message, + style = bodyStyle, + modifier = Modifier.fillMaxWidth(), + ) + entry.stackTrace?.takeIf { it.isNotBlank() }?.let { trace -> + Text( + text = trace, + style = mutedStyle, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LogEntryContent( + entry: AppLogEntry, + bodyStyle: androidx.compose.ui.text.TextStyle, + mutedStyle: androidx.compose.ui.text.TextStyle, + levelChipContentColor: Color, + isSelected: Boolean, + selectionProgress: Float, + modifier: Modifier = Modifier, +) { + DisableSelection { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + LogEntryMetadata( + entry = entry, + mutedStyle = mutedStyle, + levelChipContentColor = levelChipContentColor, + selectionProgress = selectionProgress, + isSelected = isSelected, + ) + LogEntryMessageBody( + entry = entry, + bodyStyle = bodyStyle, + mutedStyle = mutedStyle, + ) + } + } +} + +@Composable +private fun LogLevelChipContent( + level: AppLogLevel, + label: String, + containerColor: Color, + contentColor: Color, + labelStyle: androidx.compose.ui.text.TextStyle, + horizontalPadding: androidx.compose.ui.unit.Dp, + verticalPadding: androidx.compose.ui.unit.Dp, + iconSize: androidx.compose.ui.unit.Dp, + iconTextGap: androidx.compose.ui.unit.Dp, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.extraSmall, + color = containerColor, + ) { + Row( + modifier = Modifier.padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = level.icon(), + contentDescription = null, + modifier = Modifier.size(iconSize), + tint = contentColor, + ) + Text( + text = label, + modifier = Modifier.padding(start = iconTextGap), + style = labelStyle, + ) + } + } +} + +@Composable +private fun AppLogLevel.displayLabel(): String = stringResource( + when (this) { + AppLogLevel.Verbose -> Res.string.logs_level_verbose + AppLogLevel.Debug -> Res.string.logs_level_debug + AppLogLevel.Info -> Res.string.logs_level_info + AppLogLevel.Warn -> Res.string.logs_level_warn + AppLogLevel.Error -> Res.string.logs_level_error + AppLogLevel.Fatal -> Res.string.logs_level_fatal + }, +) + +@Composable +private fun logLevelChipColors(level: AppLogLevel): Pair { + val containerColor = when (level) { + AppLogLevel.Debug -> MaterialTheme.colorScheme.surfaceContainerHighest + AppLogLevel.Verbose -> MaterialTheme.colorScheme.surfaceContainerHigh + AppLogLevel.Info -> MaterialTheme.colorScheme.secondaryContainer + AppLogLevel.Warn -> MaterialTheme.colorScheme.tertiaryContainer + AppLogLevel.Error -> MaterialTheme.colorScheme.errorContainer + AppLogLevel.Fatal -> MaterialTheme.colorScheme.error + } + val contentColor = when (level) { + AppLogLevel.Debug -> MaterialTheme.colorScheme.onSurfaceVariant + AppLogLevel.Verbose -> MaterialTheme.colorScheme.onSurfaceVariant + AppLogLevel.Info -> MaterialTheme.colorScheme.onSecondaryContainer + AppLogLevel.Warn -> MaterialTheme.colorScheme.onTertiaryContainer + AppLogLevel.Error -> MaterialTheme.colorScheme.onErrorContainer + AppLogLevel.Fatal -> MaterialTheme.colorScheme.onError + } + return containerColor to contentColor +} + +private fun AppLogLevel.icon(): ImageVector = when (this) { + AppLogLevel.Verbose -> Icons.AutoMirrored.Filled.Subject + AppLogLevel.Debug -> Icons.Default.BugReport + AppLogLevel.Info -> Icons.Default.Info + AppLogLevel.Warn -> Icons.Default.Warning + AppLogLevel.Error -> Icons.Default.Error + AppLogLevel.Fatal -> Icons.Default.Report +} + +@Composable +private fun LogLevelChip( + level: AppLogLevel, + contentColorOverride: Color? = null, + modifier: Modifier = Modifier, +) { + val (containerColor, contentColor) = logLevelChipColors(level) + val resolvedContentColor = contentColorOverride ?: contentColor + val labelStyle = MaterialTheme.typography.labelSmall.copy(color = resolvedContentColor) + LogLevelChipContent( + level = level, + label = level.displayLabel(), + containerColor = containerColor, + contentColor = resolvedContentColor, + labelStyle = labelStyle, + horizontalPadding = 8.dp, + verticalPadding = 2.dp, + iconSize = 14.dp, + iconTextGap = 2.dp, + modifier = modifier, + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsSelectionUtils.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsSelectionUtils.kt new file mode 100644 index 0000000..2239726 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsSelectionUtils.kt @@ -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 = 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) }, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt index e4109ce..9fb2733 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt @@ -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" } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt index 1459f1a..ed99735 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -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) } ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 1a58a78..a2f0cf8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -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) } } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt index 52a2c02..8f7d339 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt @@ -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 ?: "") + } } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt new file mode 100644 index 0000000..0ac6c59 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt @@ -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(capacity) + val source = input.toUByteArray().toCValues() + val sourceLength = input.size.convert() + val destLength = alloc() + 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() +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt new file mode 100644 index 0000000..08980c2 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt @@ -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>, + 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(capacity) + val destLength = alloc() + destLength.value = capacity.convert() + 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() } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogShare.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogShare.ios.kt new file mode 100644 index 0000000..62f0f43 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogShare.ios.kt @@ -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) + } +}