Add logs screen

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