mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-24 12:05:04 +03:00
Add logs screen
This commit is contained in:
@@ -5,7 +5,7 @@ description: Adapts Kotlin/Compose code (diff, single file, or multiple files) t
|
|||||||
|
|
||||||
# Adapt to style
|
# 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
|
## 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.
|
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.
|
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
|
## Style adaptation pass
|
||||||
|
|
||||||
When cleaning up an existing diff or file set:
|
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).
|
1. Create or append to `.cursor/code_style_progress_<YYYYMMDD-HHmm>.md` (use current local time).
|
||||||
2. For each item:
|
2. For each item:
|
||||||
|
```markdown
|
||||||
```markdown
|
|
||||||
## relative/path/File.kt
|
## relative/path/File.kt
|
||||||
- Unsure: [specific construct and why]
|
- Unsure: [specific construct and why]
|
||||||
- Chosen approach: [what you did for now]
|
- Chosen approach: [what you did for now]
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Continue refactoring — do not block on open questions.
|
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.
|
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.
|
- Files touched and main style changes.
|
||||||
- Any entries from the progress file that need user decisions.
|
- Any entries from the progress file that need user decisions.
|
||||||
- Build result.
|
- Build result.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -200,10 +199,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(mapOf("messageIds" to messageIds))
|
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) {
|
} 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
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
@@ -8,6 +7,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.notifications.NotificationHelper
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||||
@@ -15,8 +15,11 @@ import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
|||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
Log.i("FromChatFCM", "onMessageReceived: from=${remoteMessage.from} dataSize=${remoteMessage.data.size}")
|
Logger.i(
|
||||||
Log.d("FromChatFCM", "onMessageReceived data=${remoteMessage.data}")
|
"FromChatFCM",
|
||||||
|
"onMessageReceived: from=${remoteMessage.from} dataSize=${remoteMessage.data.size}",
|
||||||
|
)
|
||||||
|
Logger.d("FromChatFCM", "onMessageReceived data=${remoteMessage.data}")
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -30,13 +33,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val messageType = pushData["type"] ?: "public_message"
|
val messageType = pushData["type"] ?: "public_message"
|
||||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||||
if (ApiClient.token.isNullOrBlank()) {
|
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()
|
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)
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
if (senderId != null && senderId == currentUserId) {
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
|
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
|
||||||
@@ -61,25 +67,23 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
NotificationHelper.fetchAndNotify(applicationContext)
|
NotificationHelper.fetchAndNotify(applicationContext)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewToken(token: String) {
|
override fun onNewToken(token: String) {
|
||||||
Log.d("FromChatFCM", "onNewToken: $token")
|
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
settings.putString("pending_fcm_token", token)
|
settings.putString("pending_fcm_token", token)
|
||||||
uploadPendingFcmTokenIfAvailable()
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onNewToken(token)
|
super.onNewToken(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.Person
|
import androidx.core.app.Person
|
||||||
@@ -25,6 +24,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.MainActivity
|
import ru.fromchat.MainActivity
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.R
|
import ru.fromchat.R
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||||
@@ -145,16 +145,16 @@ object NotificationHelper {
|
|||||||
dmMessageId: Int? = null,
|
dmMessageId: Int? = null,
|
||||||
dmSenderName: String? = null
|
dmSenderName: String? = null
|
||||||
) {
|
) {
|
||||||
Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
|
Logger.i("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val currentUserId = settings.getInt("current_user_id", -1)
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
Log.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}"
|
"fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}"
|
||||||
)
|
)
|
||||||
if (currentUserId == -1) {
|
if (currentUserId == -1) {
|
||||||
Log.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
|
Logger.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ object NotificationHelper {
|
|||||||
.body<MessagesResponse>()
|
.body<MessagesResponse>()
|
||||||
.messages
|
.messages
|
||||||
.filter { it.user_id != currentUserId }
|
.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()) {
|
if (messages.isNotEmpty()) {
|
||||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
@@ -171,7 +171,7 @@ object NotificationHelper {
|
|||||||
displayNotifications(context, messages)
|
displayNotifications(context, messages)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeDmMessages) {
|
if (includeDmMessages) {
|
||||||
@@ -180,14 +180,14 @@ object NotificationHelper {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is ClientRequestException && e.response.status.value == 401) {
|
if (e is ClientRequestException && e.response.status.value == 401) {
|
||||||
try {
|
try {
|
||||||
Log.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
|
Logger.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
|
||||||
ApiClient.loadPersistedData()
|
ApiClient.loadPersistedData()
|
||||||
val retryMessages = ApiClient.http
|
val retryMessages = ApiClient.http
|
||||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||||
.body<MessagesResponse>()
|
.body<MessagesResponse>()
|
||||||
.messages
|
.messages
|
||||||
.filter { it.user_id != settings.getInt("current_user_id", -1) }
|
.filter { it.user_id != settings.getInt("current_user_id", -1) }
|
||||||
Log.d(
|
Logger.i(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||||
)
|
)
|
||||||
@@ -202,10 +202,10 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
} catch (_: Exception) {
|
} 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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ object NotificationHelper {
|
|||||||
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
|
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
|
||||||
throw throwable
|
throw throwable
|
||||||
}
|
}
|
||||||
Log.e(
|
Logger.e(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"fetchAndNotifyDirectMessages: failed to fetch dm messages for since=$sinceId: ${throwable.message}",
|
"fetchAndNotifyDirectMessages: failed to fetch dm messages for since=$sinceId: ${throwable.message}",
|
||||||
throwable
|
throwable
|
||||||
@@ -252,7 +252,7 @@ object NotificationHelper {
|
|||||||
dmSenderName: String?
|
dmSenderName: String?
|
||||||
) {
|
) {
|
||||||
val dmMessages = response.messages
|
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()) {
|
if (dmMessages.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,7 +271,7 @@ object NotificationHelper {
|
|||||||
val shownDmKey = "dm:$envelopeId"
|
val shownDmKey = "dm:$envelopeId"
|
||||||
|
|
||||||
if (shownDm.contains(shownDmKey) || envelopeId <= latestMessageId) {
|
if (shownDm.contains(shownDmKey) || envelopeId <= latestMessageId) {
|
||||||
Log.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"Direct notification skipped: already shown envelopeId=$envelopeId"
|
"Direct notification skipped: already shown envelopeId=$envelopeId"
|
||||||
)
|
)
|
||||||
@@ -283,7 +283,7 @@ object NotificationHelper {
|
|||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
when (throwable) {
|
when (throwable) {
|
||||||
is DmCiphertextCorruptedException -> {
|
is DmCiphertextCorruptedException -> {
|
||||||
Log.w(
|
Logger.w(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"DM decrypt failed for envelopeId=$envelopeId"
|
"DM decrypt failed for envelopeId=$envelopeId"
|
||||||
)
|
)
|
||||||
@@ -291,7 +291,7 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.w(
|
Logger.w(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}",
|
"DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}",
|
||||||
throwable
|
throwable
|
||||||
@@ -355,16 +355,16 @@ object NotificationHelper {
|
|||||||
|
|
||||||
val currentUserId = settings.getInt("current_user_id", -1)
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
if (!isDirectMessage && senderId != null && senderId == currentUserId) {
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) {
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPublicChatVisible && !allowWhenPublicChatVisible) {
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ object NotificationHelper {
|
|||||||
Manifest.permission.POST_NOTIFICATIONS
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
Log.w(
|
Logger.w(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"Fallback push notification skipped: POST_NOTIFICATIONS permission missing"
|
"Fallback push notification skipped: POST_NOTIFICATIONS permission missing"
|
||||||
)
|
)
|
||||||
@@ -385,7 +385,7 @@ object NotificationHelper {
|
|||||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||||
val shownKey = if (isDirectMessage) "dm:${messageId}" else messageId?.toString()
|
val shownKey = if (isDirectMessage) "dm:${messageId}" else messageId?.toString()
|
||||||
if (messageId != null && shown.contains(shownKey)) {
|
if (messageId != null && shown.contains(shownKey)) {
|
||||||
Log.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"Fallback push notification skipped: already shown messageId=$messageId"
|
"Fallback push notification skipped: already shown messageId=$messageId"
|
||||||
)
|
)
|
||||||
@@ -446,20 +446,20 @@ object NotificationHelper {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||||
Log.d(
|
Logger.i(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"Fallback push notification shown title=$title sender=$senderName messageId=$messageId"
|
"Fallback push notification shown messageId=$messageId"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
private fun displayNotifications(context: Context, messages: List<Message>) {
|
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
|
// Don't show notifications if user is currently viewing the public chat
|
||||||
if (isPublicChatVisible) {
|
if (isPublicChatVisible) {
|
||||||
Log.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@ object NotificationHelper {
|
|||||||
msg.user_id != currentUserId // Not from current user
|
msg.user_id != currentUserId // Not from current user
|
||||||
}
|
}
|
||||||
if (newMessages.isEmpty()) {
|
if (newMessages.isEmpty()) {
|
||||||
Log.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"displayNotifications: no new messages after filters for user=$currentUserId"
|
"displayNotifications: no new messages after filters for user=$currentUserId"
|
||||||
)
|
)
|
||||||
@@ -494,7 +494,7 @@ object NotificationHelper {
|
|||||||
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
||||||
|
|
||||||
newMessageCount = newMessages.size
|
newMessageCount = newMessages.size
|
||||||
Log.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
||||||
)
|
)
|
||||||
@@ -553,7 +553,7 @@ object NotificationHelper {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Log.w(
|
Logger.w(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||||
)
|
)
|
||||||
@@ -561,7 +561,7 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
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.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
|
||||||
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
|
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)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
class NotificationReplyReceiver : BroadcastReceiver() {
|
class NotificationReplyReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
Log.e(
|
Logger.d(
|
||||||
"NotificationReply",
|
"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 ->
|
val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input ->
|
||||||
@@ -33,12 +33,12 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
?.toString()
|
?.toString()
|
||||||
?.trim()
|
?.trim()
|
||||||
?: run {
|
?: run {
|
||||||
Log.w("NotificationReply", "No inline reply text found")
|
Logger.w("NotificationReply", "No inline reply text found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyText.isBlank()) {
|
if (replyText.isBlank()) {
|
||||||
Log.w("NotificationReply", "Inline reply text is blank")
|
Logger.w("NotificationReply", "Inline reply text is blank")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
val targetDmUserId = intent.getIntExtra(EXTRA_REPLY_DM_USER_ID, -1)
|
val targetDmUserId = intent.getIntExtra(EXTRA_REPLY_DM_USER_ID, -1)
|
||||||
val parentMessageId = intent.getIntExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, -1).takeIf { it > 0 }
|
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())
|
NotificationManagerCompat.from(context).cancel(NotificationHelper.summaryNotificationId())
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
@@ -64,7 +64,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
replyToId = parentMessageId
|
replyToId = parentMessageId
|
||||||
)
|
)
|
||||||
} else {
|
} 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
|
replyToId = parentMessageId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.d("NotificationReply", "Reply dispatch attempt completed for $chatType")
|
Logger.i("NotificationReply", "Reply dispatch attempt completed for $chatType")
|
||||||
} catch (e: Exception) {
|
} 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
|
package ru.fromchat
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import ru.fromchat.logging.AppLogLevel
|
||||||
|
import ru.fromchat.logging.AppLogStore
|
||||||
|
|
||||||
actual object Logger {
|
actual object Logger {
|
||||||
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Debug, tag, message, throwable)
|
||||||
Log.d(tag, message, throwable)
|
Log.d(tag, message, throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Info, tag, message, throwable)
|
||||||
Log.i(tag, message, throwable)
|
Log.i(tag, message, throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Warn, tag, message, throwable)
|
||||||
Log.w(tag, message, throwable)
|
Log.w(tag, message, throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Error, tag, message, throwable)
|
||||||
Log.e(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
|
package ru.fromchat.api
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.android.gms.tasks.Task
|
import com.google.android.gms.tasks.Task
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
@@ -11,6 +10,7 @@ import io.ktor.client.request.setBody
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -41,10 +41,10 @@ private suspend fun postFcmToken(token: String): Boolean {
|
|||||||
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
||||||
}
|
}
|
||||||
.body<SimpleStatusResponse>()
|
.body<SimpleStatusResponse>()
|
||||||
Log.d("FcmReg", "Uploaded FCM token to server: ...$suffix")
|
Logger.i("FcmReg", "Uploaded FCM token to server: ...$suffix")
|
||||||
true
|
true
|
||||||
}.getOrElse { e ->
|
}.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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,9 +53,8 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
|
|||||||
try {
|
try {
|
||||||
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
|
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
|
||||||
|
|
||||||
// Only upload if we have auth token
|
|
||||||
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
|
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
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,16 +62,16 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
|
|||||||
settings.putString(CURRENT_FCM_TOKEN_KEY, pending)
|
settings.putString(CURRENT_FCM_TOKEN_KEY, pending)
|
||||||
settings.remove(PENDING_FCM_TOKEN_KEY)
|
settings.remove(PENDING_FCM_TOKEN_KEY)
|
||||||
} else {
|
} else {
|
||||||
Log.d("FcmReg", "Deferring pending FCM token upload")
|
Logger.d("FcmReg", "Deferring pending FCM token upload")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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) {
|
actual suspend fun ensureFcmTokenRegistered(): Boolean = withContext(Dispatchers.IO) {
|
||||||
if (ApiClient.token.isNullOrEmpty()) {
|
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
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,19 +91,19 @@ actual suspend fun ensureFcmTokenRegistered(): Boolean = withContext(Dispatchers
|
|||||||
}
|
}
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("FcmReg", "ensureFcmTokenRegistered error: ${e.message}")
|
Logger.e("FcmReg", "ensureFcmTokenRegistered error: ${e.message}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatchers.IO) {
|
actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatchers.IO) {
|
||||||
if (ApiClient.token.isNullOrEmpty()) {
|
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
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim()
|
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 {
|
return@withContext runCatching {
|
||||||
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
|
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
@@ -118,7 +117,7 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
Log.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
|
Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ actual suspend fun openCachedAttachmentFile(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
context.startActivity(primary)
|
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) {
|
} catch (t: Throwable) {
|
||||||
Logger.w(tag, "startActivity primary failed, falling back mime=$resolvedMime uri=$contentUri", t)
|
Logger.w(tag, "startActivity primary failed, falling back mime=$resolvedMime uri=$contentUri", t)
|
||||||
context.startActivity(fallback)
|
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,
|
TAG,
|
||||||
"RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " +
|
"RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " +
|
||||||
"(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)",
|
"(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)",
|
||||||
@@ -435,7 +435,7 @@ actual fun CallMediaLayer(
|
|||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
reconnectGeneration += 1 // invalidate any pending reconnect
|
reconnectGeneration += 1 // invalidate any pending reconnect
|
||||||
|
|
||||||
Logger.d(
|
Logger.i(
|
||||||
TAG,
|
TAG,
|
||||||
"RoomScope onConnected state=${room.state} micReq=$micRequestedOn " +
|
"RoomScope onConnected state=${room.state} micReq=$micRequestedOn " +
|
||||||
"micEn=${room.localParticipant.isMicrophoneEnabled}",
|
"micEn=${room.localParticipant.isMicrophoneEnabled}",
|
||||||
@@ -458,7 +458,7 @@ actual fun CallMediaLayer(
|
|||||||
connectionStatusText = null
|
connectionStatusText = null
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
reconnectGeneration += 1 // invalidate any pending reconnect
|
reconnectGeneration += 1 // invalidate any pending reconnect
|
||||||
Logger.d(TAG, "RoomEvent.Connected")
|
Logger.i(TAG, "RoomEvent.Connected")
|
||||||
}
|
}
|
||||||
is RoomEvent.Disconnected -> {
|
is RoomEvent.Disconnected -> {
|
||||||
val detail = event.error?.message ?: event.reason.toString()
|
val detail = event.error?.message ?: event.reason.toString()
|
||||||
@@ -483,7 +483,7 @@ actual fun CallMediaLayer(
|
|||||||
}
|
}
|
||||||
is RoomEvent.Reconnected -> {
|
is RoomEvent.Reconnected -> {
|
||||||
connectionStatusText = null
|
connectionStatusText = null
|
||||||
Logger.d(TAG, "RoomEvent.Reconnected")
|
Logger.i(TAG, "RoomEvent.Reconnected")
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,6 +333,56 @@
|
|||||||
<string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string>
|
<string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string>
|
||||||
<string name="settings_next">Далее</string>
|
<string name="settings_next">Далее</string>
|
||||||
<string name="settings_hub_about_sub">Версия, ссылки и другое</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_title">Аккаунт</string>
|
||||||
<string name="settings_account_logout_confirm_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_security_step_confirm_body">Type your new password again to make sure it matches.</string>
|
||||||
<string name="settings_next">Next</string>
|
<string name="settings_next">Next</string>
|
||||||
<string name="settings_hub_about_sub">Version, links, and more</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_title">Account</string>
|
||||||
<string name="settings_account_logout_confirm_title">Log out?</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 i(tag: String, message: String, throwable: Throwable? = null)
|
||||||
fun w(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 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
|
val startSeq = _lastSeq.value
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.d("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
|
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
|
||||||
if (startSeq > 0) {
|
if (startSeq > 0) {
|
||||||
ConnectionStateStore.onUpdating(start = true)
|
ConnectionStateStore.onUpdating(start = true)
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ object UpdateSyncManager {
|
|||||||
if (data != null) {
|
if (data != null) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
||||||
Logger.d(
|
Logger.i(
|
||||||
"UpdateSyncManager",
|
"UpdateSyncManager",
|
||||||
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
|
"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()
|
val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
if (_ui.value is CallUiState.InCall) return
|
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(
|
_ui.value = CallUiState.Incoming(
|
||||||
fromUserId = fromUserId,
|
fromUserId = fromUserId,
|
||||||
fromUsername = fromUsername,
|
fromUsername = fromUsername,
|
||||||
@@ -126,7 +126,7 @@ object CallStore {
|
|||||||
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
|
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Logger.d(TAG, "startOutgoingCall(peer=$peerUserId)")
|
Logger.i(TAG, "startOutgoingCall(peer=$peerUserId)")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Stay on underlying UI until the room is ready; do not block on callee answering.
|
// Stay on underlying UI until the room is ready; do not block on callee answering.
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -146,7 +146,7 @@ object CallStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { session ->
|
}.onSuccess { session ->
|
||||||
Logger.d(
|
Logger.i(
|
||||||
TAG,
|
TAG,
|
||||||
"startOutgoingCall → InCall peer=${session.peerUserId} room=${session.roomName}",
|
"startOutgoingCall → InCall peer=${session.peerUserId} room=${session.roomName}",
|
||||||
)
|
)
|
||||||
@@ -183,7 +183,7 @@ object CallStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { session ->
|
}.onSuccess { session ->
|
||||||
Logger.d(
|
Logger.i(
|
||||||
TAG,
|
TAG,
|
||||||
"acceptIncoming → InCall peer=${session.peerUserId} room=${session.roomName}",
|
"acceptIncoming → InCall peer=${session.peerUserId} room=${session.roomName}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ object WebSocketManager {
|
|||||||
if (AppForeground.isInForeground.value) Logger.d(TAG, message)
|
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) {
|
private fun logW(message: String, throwable: Throwable? = null) {
|
||||||
if (AppForeground.isInForeground.value) Logger.w(TAG, message, throwable)
|
if (AppForeground.isInForeground.value) Logger.w(TAG, message, throwable)
|
||||||
}
|
}
|
||||||
@@ -197,7 +201,7 @@ object WebSocketManager {
|
|||||||
) {
|
) {
|
||||||
session = this
|
session = this
|
||||||
connecting = false
|
connecting = false
|
||||||
logD("WebSocket connected. connecting set to false")
|
logI("WebSocket connected. connecting set to false")
|
||||||
ConnectionStateStore.onConnected()
|
ConnectionStateStore.onConnected()
|
||||||
|
|
||||||
logD("Sending WebSocket ping for authentication")
|
logD("Sending WebSocket ping for authentication")
|
||||||
@@ -342,7 +346,7 @@ object WebSocketManager {
|
|||||||
|
|
||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
disconnect()
|
disconnect()
|
||||||
logD("shutdown() called. Cancelling scope.")
|
logI("shutdown() called. Cancelling scope.")
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +357,7 @@ object WebSocketManager {
|
|||||||
session?.cancel()
|
session?.cancel()
|
||||||
session = null
|
session = null
|
||||||
connecting = false
|
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() {
|
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.chat.panels.publicchat.PublicChatProfileRoute
|
||||||
import ru.fromchat.ui.main.MainScreen
|
import ru.fromchat.ui.main.MainScreen
|
||||||
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
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.AboutScreen
|
||||||
import ru.fromchat.ui.main.settings.AppearanceScreen
|
import ru.fromchat.ui.main.settings.AppearanceScreen
|
||||||
import ru.fromchat.ui.main.settings.DevicesScreen
|
import ru.fromchat.ui.main.settings.DevicesScreen
|
||||||
@@ -224,6 +227,7 @@ fun App(
|
|||||||
runCatching { ensureFromChatCacheGeneration() }
|
runCatching { ensureFromChatCacheGeneration() }
|
||||||
runCatching { NetworkConnectivity.ensureStarted() }
|
runCatching { NetworkConnectivity.ensureStarted() }
|
||||||
runCatching { ApiClient.loadPersistedData() }
|
runCatching { ApiClient.loadPersistedData() }
|
||||||
|
Logger.i("App", "FromChat started")
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasToken = ApiClient.token?.isNotEmpty() == true
|
val hasToken = ApiClient.token?.isNotEmpty() == true
|
||||||
@@ -313,7 +317,7 @@ fun App(
|
|||||||
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
|
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
|
||||||
LaunchedEffect(profileLookupErrorMessage) {
|
LaunchedEffect(profileLookupErrorMessage) {
|
||||||
profileLookupErrorMessage?.let { message ->
|
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(
|
profileLookupSnackbarHostState.showSnackbar(
|
||||||
message = message,
|
message = message,
|
||||||
withDismissAction = true,
|
withDismissAction = true,
|
||||||
@@ -593,6 +597,21 @@ fun App(
|
|||||||
AboutScreen()
|
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(
|
settingsComposable(
|
||||||
route = DocumentType.ROUTE,
|
route = DocumentType.ROUTE,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
@@ -660,7 +679,7 @@ fun App(
|
|||||||
|
|
||||||
DisposableEffect(navController) {
|
DisposableEffect(navController) {
|
||||||
ApiClient.onAuthError = {
|
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 {
|
runCatching {
|
||||||
navController.navigateAndWipeBackStack("welcome")
|
navController.navigateAndWipeBackStack("welcome")
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
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.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -24,11 +35,15 @@ import coil3.compose.AsyncImage
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.about
|
||||||
import ru.fromchat.auth_get_started
|
import ru.fromchat.auth_get_started
|
||||||
import ru.fromchat.auth_welcome_tagline
|
import ru.fromchat.auth_welcome_tagline
|
||||||
import ru.fromchat.auth_welcome_title
|
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.ActionButton
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -45,46 +60,83 @@ fun WelcomeScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets.safeDrawing,
|
contentWindowInsets = WindowInsets.safeDrawing,
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
val navController = LocalNavController.current
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding),
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
IconButton(
|
||||||
model = Res.getUri("drawable/logo_square.svg"),
|
onClick = { menuExpanded = true },
|
||||||
contentDescription = null,
|
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
|
modifier = Modifier
|
||||||
.size(112.dp)
|
.fillMaxSize()
|
||||||
.clip(MaterialTheme.shapes.extraLarge),
|
.navigationBarsPadding()
|
||||||
contentScale = ContentScale.Crop,
|
.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(
|
||||||
text = stringResource(Res.string.auth_welcome_title),
|
text = stringResource(Res.string.auth_welcome_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.auth_welcome_tagline),
|
text = stringResource(Res.string.auth_welcome_tagline),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(40.dp))
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
ActionButton(onClick = onGetStarted) {
|
ActionButton(onClick = onGetStarted) {
|
||||||
Text(stringResource(Res.string.auth_get_started))
|
Text(stringResource(Res.string.auth_get_started))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
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 AccountDeleteFlow = "settings/account/delete"
|
||||||
const val ServerConfig = "serverConfig"
|
const val ServerConfig = "serverConfig"
|
||||||
const val About = "about"
|
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.Devices
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.Palette
|
||||||
import androidx.compose.material.icons.filled.Storage
|
import androidx.compose.material.icons.filled.Storage
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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_devices_d
|
||||||
import ru.fromchat.settings_category_notifications
|
import ru.fromchat.settings_category_notifications
|
||||||
import ru.fromchat.settings_category_notifications_d
|
import ru.fromchat.settings_category_notifications_d
|
||||||
|
import ru.fromchat.logs_title
|
||||||
import ru.fromchat.settings_hub_about_sub
|
import ru.fromchat.settings_hub_about_sub
|
||||||
|
import ru.fromchat.settings_hub_logs_sub
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
|
||||||
val SettingsStepHorizontalPadding = 24.dp
|
val SettingsStepHorizontalPadding = 24.dp
|
||||||
@@ -113,7 +116,15 @@ fun SettingsTab() {
|
|||||||
headline = stringResource(Res.string.about),
|
headline = stringResource(Res.string.about),
|
||||||
supportingText = stringResource(Res.string.settings_hub_about_sub),
|
supportingText = stringResource(Res.string.settings_hub_about_sub),
|
||||||
onClick = { navController.navigate(SettingsRoutes.About) },
|
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 dev.chrisbanes.haze.rememberHazeState
|
||||||
import io.ktor.client.plugins.ClientRequestException
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -316,18 +318,18 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (isActive) {
|
var loadedSuccessfully = false
|
||||||
if (!WebSocketManager.isConnected) {
|
|
||||||
delay(1000)
|
suspend fun attemptLoad(): Boolean {
|
||||||
continue
|
if (loadedSuccessfully || !currentCoroutineContext().isActive) return loadedSuccessfully
|
||||||
}
|
if (!WebSocketManager.isConnected) return false
|
||||||
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileScreen",
|
"ProfileScreen",
|
||||||
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
|
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
return try {
|
||||||
val profile = when {
|
val profile = when {
|
||||||
targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile()
|
targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile()
|
||||||
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
|
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
|
||||||
@@ -348,6 +350,7 @@ fun ProfileScreen(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = ProfileLoadError.Generic
|
error = ProfileLoadError.Generic
|
||||||
)
|
)
|
||||||
|
false
|
||||||
} else {
|
} else {
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileScreen",
|
"ProfileScreen",
|
||||||
@@ -359,6 +362,8 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
ProfileCache.put(profile)
|
ProfileCache.put(profile)
|
||||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||||
|
loadedSuccessfully = true
|
||||||
|
true
|
||||||
}
|
}
|
||||||
} catch (err: Exception) {
|
} catch (err: Exception) {
|
||||||
val fallback = resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
val fallback = resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
||||||
@@ -415,9 +420,20 @@ fun ProfileScreen(
|
|||||||
profile = resolvedProfile,
|
profile = resolvedProfile,
|
||||||
isLoading = false
|
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
|
package ru.fromchat
|
||||||
|
|
||||||
import platform.Foundation.NSLog
|
import platform.Foundation.NSLog
|
||||||
|
import ru.fromchat.logging.AppLogLevel
|
||||||
|
import ru.fromchat.logging.AppLogStore
|
||||||
|
|
||||||
actual object Logger {
|
actual object Logger {
|
||||||
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
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 ?: "")
|
NSLog("DEBUG: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
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 ?: "")
|
NSLog("INFO: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
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 ?: "")
|
NSLog("WARN: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
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 ?: "")
|
NSLog("ERROR: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun f(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Fatal, tag, message, throwable)
|
||||||
|
NSLog("FATAL: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ru.fromchat.logging
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.UByteVar
|
||||||
|
import kotlinx.cinterop.alloc
|
||||||
|
import kotlinx.cinterop.allocArray
|
||||||
|
import kotlinx.cinterop.convert
|
||||||
|
import kotlinx.cinterop.memScoped
|
||||||
|
import kotlinx.cinterop.ptr
|
||||||
|
import kotlinx.cinterop.reinterpret
|
||||||
|
import kotlinx.cinterop.sizeOf
|
||||||
|
import kotlinx.cinterop.toCValues
|
||||||
|
import platform.zlib.Z_DEFAULT_COMPRESSION
|
||||||
|
import platform.zlib.compress2
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
internal actual fun gzipCompress(input: ByteArray): ByteArray {
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
return byteArrayOf(0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val header = byteArrayOf(
|
||||||
|
0x1f,
|
||||||
|
0x8b.toByte(),
|
||||||
|
0x08,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x03,
|
||||||
|
)
|
||||||
|
|
||||||
|
val deflated = deflateRaw(input)
|
||||||
|
val crc = crc32(input)
|
||||||
|
val isize = input.size
|
||||||
|
|
||||||
|
val footer = ByteArray(8)
|
||||||
|
footer[0] = (crc and 0xFF).toByte()
|
||||||
|
footer[1] = ((crc shr 8) and 0xFF).toByte()
|
||||||
|
footer[2] = ((crc shr 16) and 0xFF).toByte()
|
||||||
|
footer[3] = ((crc shr 24) and 0xFF).toByte()
|
||||||
|
footer[4] = (isize and 0xFF).toByte()
|
||||||
|
footer[5] = ((isize shr 8) and 0xFF).toByte()
|
||||||
|
footer[6] = ((isize shr 16) and 0xFF).toByte()
|
||||||
|
footer[7] = ((isize shr 24) and 0xFF).toByte()
|
||||||
|
|
||||||
|
return header + deflated + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun deflateRaw(input: ByteArray): ByteArray = memScoped {
|
||||||
|
if (input.isEmpty()) return@memScoped ByteArray(0)
|
||||||
|
|
||||||
|
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
|
||||||
|
while (true) {
|
||||||
|
val output = allocArray<UByteVar>(capacity)
|
||||||
|
val source = input.toUByteArray().toCValues()
|
||||||
|
val sourceLength = input.size.convert<platform.zlib.uLong>()
|
||||||
|
val destLength = alloc<platform.zlib.uLongVar>()
|
||||||
|
destLength.value = capacity.convert()
|
||||||
|
|
||||||
|
val status = compress2(
|
||||||
|
output,
|
||||||
|
destLength.ptr,
|
||||||
|
source.ptr.reinterpret(),
|
||||||
|
sourceLength,
|
||||||
|
Z_DEFAULT_COMPRESSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (status == 0) {
|
||||||
|
val size = destLength.value.toInt()
|
||||||
|
return@memScoped ByteArray(size) { index -> output[index].toByte() }
|
||||||
|
}
|
||||||
|
|
||||||
|
capacity *= 2
|
||||||
|
if (capacity > input.size * 20) {
|
||||||
|
return@memScoped input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toUByteArray(): UByteArray = UByteArray(size) { this[it].toUByte() }
|
||||||
|
|
||||||
|
private fun crc32(data: ByteArray): Int {
|
||||||
|
var crc = 0xFFFFFFFF.toInt()
|
||||||
|
for (byte in data) {
|
||||||
|
crc = crc xor (byte.toInt() and 0xFF)
|
||||||
|
repeat(8) {
|
||||||
|
crc = if (crc and 1 != 0) {
|
||||||
|
0xEDB88320.toInt() xor (crc ushr 1)
|
||||||
|
} else {
|
||||||
|
crc ushr 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc.inv()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package ru.fromchat.logging
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.UByteVar
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.alloc
|
||||||
|
import kotlinx.cinterop.allocArray
|
||||||
|
import kotlinx.cinterop.convert
|
||||||
|
import kotlinx.cinterop.memScoped
|
||||||
|
import kotlinx.cinterop.ptr
|
||||||
|
import kotlinx.cinterop.reinterpret
|
||||||
|
import kotlinx.cinterop.toCValues
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSString
|
||||||
|
import platform.Foundation.NSUTF8StringEncoding
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.dataWithContentsOfFile
|
||||||
|
import platform.Foundation.writeToFile
|
||||||
|
import platform.posix.memcpy
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
internal actual object LogFileOps {
|
||||||
|
actual fun readText(path: String): String {
|
||||||
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ""
|
||||||
|
return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as? String
|
||||||
|
?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun readBytes(path: String): ByteArray {
|
||||||
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ByteArray(0)
|
||||||
|
val raw = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
||||||
|
return raw.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.Default) {
|
||||||
|
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
|
||||||
|
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
|
||||||
|
val bytes = raw.toByteArray()
|
||||||
|
val gzipped = gzipCompress(bytes)
|
||||||
|
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||||
|
NSData.create(bytes = gzipped, length = gzipped.size.toULong())
|
||||||
|
.writeToFile(destinationPath, true)
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val bytes = gunzipToByteArray(path, onProgress)
|
||||||
|
bytes.decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun gunzipToFile(
|
||||||
|
sourcePath: String,
|
||||||
|
destinationPath: String,
|
||||||
|
onProgress: (Float) -> Unit,
|
||||||
|
) = withContext(Dispatchers.Default) {
|
||||||
|
val bytes = gunzipToByteArray(sourcePath, onProgress)
|
||||||
|
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||||
|
NSData.create(bytes = bytes, length = bytes.size.toULong())
|
||||||
|
.writeToFile(destinationPath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun zipFiles(
|
||||||
|
entries: List<Pair<String, String>>,
|
||||||
|
destinationPath: String,
|
||||||
|
) = withContext(Dispatchers.Default) {
|
||||||
|
if (entries.isEmpty()) return@withContext
|
||||||
|
val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
|
||||||
|
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
|
||||||
|
ZipFileEntry(entryName, readBytes(sourcePath))
|
||||||
|
}
|
||||||
|
val bytes = buildStoreZipArchive(zipEntries)
|
||||||
|
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||||
|
NSData.create(bytes = bytes, length = bytes.size.toULong())
|
||||||
|
.writeToFile(destinationPath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
|
||||||
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
|
||||||
|
onProgress(1f)
|
||||||
|
return ByteArray(0)
|
||||||
|
}
|
||||||
|
val raw = NSData.dataWithContentsOfFile(path) ?: run {
|
||||||
|
onProgress(1f)
|
||||||
|
return ByteArray(0)
|
||||||
|
}
|
||||||
|
val compressed = raw.toByteArray()
|
||||||
|
if (compressed.size < 18) {
|
||||||
|
onProgress(1f)
|
||||||
|
return ByteArray(0)
|
||||||
|
}
|
||||||
|
val deflated = compressed.copyOfRange(10, compressed.size - 8)
|
||||||
|
val inflated = inflateGzipPayload(deflated)
|
||||||
|
onProgress(1f)
|
||||||
|
return inflated
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
|
||||||
|
if (deflated.isEmpty()) return@memScoped ByteArray(0)
|
||||||
|
|
||||||
|
var capacity = (deflated.size * 4).coerceAtLeast(256)
|
||||||
|
while (capacity <= deflated.size * 32) {
|
||||||
|
val output = allocArray<UByteVar>(capacity)
|
||||||
|
val destLength = alloc<platform.zlib.uLongVar>()
|
||||||
|
destLength.value = capacity.convert<platform.zlib.uLong>()
|
||||||
|
val source = deflated.toUByteArray().toCValues()
|
||||||
|
val status = platform.zlib.uncompress(
|
||||||
|
output,
|
||||||
|
destLength.ptr,
|
||||||
|
source.ptr.reinterpret(),
|
||||||
|
deflated.size.convert(),
|
||||||
|
)
|
||||||
|
if (status == 0) {
|
||||||
|
val size = destLength.value.toInt()
|
||||||
|
return@memScoped ByteArray(size) { index -> output[index].toByte() }
|
||||||
|
}
|
||||||
|
capacity *= 2
|
||||||
|
}
|
||||||
|
ByteArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun NSData.toByteArray(): ByteArray {
|
||||||
|
val length = this.length.toInt()
|
||||||
|
if (length == 0) return ByteArray(0)
|
||||||
|
val bytes = ByteArray(length)
|
||||||
|
bytes.usePinned { pinned ->
|
||||||
|
memcpy(pinned.addressOf(0), this.bytes, this.length)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toUByteArray(): UByteArray = UByteArray(size) { this[it].toUByte() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package ru.fromchat.logging
|
||||||
|
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.UIKit.UIActivityViewController
|
||||||
|
import platform.UIKit.UIApplication
|
||||||
|
|
||||||
|
actual object LogShare {
|
||||||
|
actual fun shareText(title: String, text: String) {
|
||||||
|
val controller = UIActivityViewController(
|
||||||
|
activityItems = listOf(text),
|
||||||
|
applicationActivities = null,
|
||||||
|
)
|
||||||
|
present(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun shareFile(title: String, filePath: String, mimeType: String) {
|
||||||
|
val url = NSURL.fileURLWithPath(filePath)
|
||||||
|
val controller = UIActivityViewController(
|
||||||
|
activityItems = listOf(url),
|
||||||
|
applicationActivities = null,
|
||||||
|
)
|
||||||
|
present(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun present(controller: UIActivityViewController) {
|
||||||
|
val root = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return
|
||||||
|
root.presentViewController(controller, animated = true, completion = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user