Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ captures/

# Native / Rust
native/*-rs/target/
native/mtproto-transport/target/
vendor/libvpx/
native/vpx/build/
native/vpx/tmp/
Expand Down
64 changes: 59 additions & 5 deletions app/src/main/java/org/monogram/MonogramApp.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.monogram

import android.app.Application
import androidx.compose.material3.ComposeMaterial3Flags
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
Expand All @@ -12,10 +13,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.monogram.core.common.AppLog
import org.monogram.core.common.DebugLog
import org.monogram.core.common.DebugStats
import org.monogram.core.common.Outcome
import org.monogram.core.common.SponsorRegistry
import org.monogram.core.common.PerfLog
import org.monogram.core.common.SponsorRegistry
import org.monogram.core.common.TelegramCredentials
import org.monogram.core.common.push.NotificationLocalStore
import org.monogram.core.database.DatabaseProvider
Expand All @@ -29,7 +31,12 @@ import org.monogram.core.ui.DownloadSettings
import org.monogram.core.ui.DownloadState
import org.monogram.core.ui.ImageCache
import org.monogram.core.ui.perf.perfSpan
import org.monogram.feature.settings.ProxySettingsStore
import org.monogram.mtproto.MtprotoNativeLoader
import org.monogram.network.bridge.BridgedMtprotoClient
import org.monogram.network.bridge.MtprotoTransportMode
import org.monogram.network.bridge.ProxyConfig
import org.monogram.network.bridge.ProxyType
import org.monogram.network.http.MediaFetchKind
import org.monogram.network.http.MediaPriority
import org.monogram.network.http.MediaRepository
Expand Down Expand Up @@ -90,14 +97,15 @@ class MonogramApp : Application() {
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
override fun onCreate() {
super.onCreate()
androidx.compose.material3.ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true
ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true
val startedAt = PerfLog.nowMs()
perfSpan("app:settings") {
AppLog.init(cacheDir)
}
settingsScope.launch(Dispatchers.IO) {
perfSpan("app:debugStats") {
DebugStats.install(BuildConfig.DEBUG, cacheDir)
DebugLog.install(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "beta")
}
}
settingsScope.launch(Dispatchers.IO) {
Expand Down Expand Up @@ -139,6 +147,7 @@ class MonogramApp : Application() {
}
},
createClient = {
MtprotoNativeLoader.initializePlatformVerifier(this)
val sessionFile = File(filesDir, "mtproto.session.json")
perfSpan("app:tdlibImport") {
runCatching { TdlibSessionImport.maybeImport(filesDir, sessionFile, cacheDir) }
Expand All @@ -150,6 +159,32 @@ class MonogramApp : Application() {
sessionPath = sessionFile.absolutePath,
)
}
ProxySettingsStore.load(this)?.let { saved ->
val applied = runCatching {
val transportMode =
MtprotoTransportMode.valueOf(saved.transportMode.uppercase())
val proxyType = ProxyType.valueOf(saved.kind)
if (proxyType == ProxyType.NONE) false else
client.setTransportMode(transportMode) is Outcome.Ok &&
client.configureProxy(
ProxyConfig(
proxyType,
saved.host,
saved.port,
saved.username,
saved.password,
saved.secret
)
) is Outcome.Ok
}.getOrDefault(false)
if (!applied) {
runCatching { client.clearProxy() }
runCatching { client.setTransportMode(MtprotoTransportMode.PADDED_INTERMEDIATE) }
ProxySettingsStore.clearActive(this)
ProxySettingsStore.markStartupFailure(this)
AppLog.warn("proxy", "stored proxy settings rejected")
}
}
},
installImageCache = {
perfSpan("app:imageCache") { ImageCache.install(this) }
Expand All @@ -164,8 +199,10 @@ class MonogramApp : Application() {
when (val result = runCatching { client.connect() }.getOrNull()) {
is Outcome.Ok ->
PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=ok")

is Outcome.Err ->
PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=err")

null ->
PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=throw")
}
Expand Down Expand Up @@ -193,11 +230,28 @@ class MonogramApp : Application() {
telegramFetcher = TelegramMediaFetcher { chatId, messageId, destPath, kind, priority ->
when (kind) {
MediaFetchKind.Thumb ->
client.downloadMessageThumb(chatId, messageId, destPath, priority)
client.downloadMessageThumb(
chatId,
messageId,
destPath,
priority
)

MediaFetchKind.Display ->
client.downloadMessageDisplay(chatId, messageId, destPath, priority)
client.downloadMessageDisplay(
chatId,
messageId,
destPath,
priority
)

MediaFetchKind.Full ->
client.downloadMessageMedia(chatId, messageId, destPath, priority)
client.downloadMessageMedia(
chatId,
messageId,
destPath,
priority
)
}
},
customEmojiFetcher = { documentId, destPath, priority ->
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/java/org/monogram/root/RootComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ class RootComponent(
}
}

private fun openSettings(openFolders: Boolean) {
private fun openSettings(openFolders: Boolean = false, openProxy: Boolean = false) {
navigation.navigate { configurations ->
uniqueStack(configurations, Config.Settings(openFolders = openFolders))
uniqueStack(configurations, Config.Settings(openFolders = openFolders, openProxy = openProxy))
}
}

Expand Down Expand Up @@ -426,7 +426,8 @@ class RootComponent(
mediaRepository = mediaRepository,
onOpenChat = ::openChatFromList,
onOpenProfile = ::openProfile,
onOpenSettings = { openSettings(openFolders = false) },
onOpenSettings = { openSettings() },
onOpenProxy = { openSettings(openProxy = true) },
onOpenFolders = { openSettings(openFolders = true) },
),
)
Expand Down Expand Up @@ -512,6 +513,7 @@ class RootComponent(
debugNotifications = BuildConfig.DEBUG,
notificationLocal = notificationLocal,
openFolders = config.openFolders,
openProxy = config.openProxy,
appUpdate = appUpdate,
updatesEnabled = AppUpdate.inAppUpdatesEnabled(BuildConfig.BUILD_TYPE),
),
Expand Down Expand Up @@ -561,7 +563,7 @@ class RootComponent(
data class Profile(val peerId: Long) : Config

@Serializable
data class Settings(val openFolders: Boolean = false) : Config
data class Settings(val openFolders: Boolean = false, val openProxy: Boolean = false) : Config
}
}

Expand Down Expand Up @@ -604,6 +606,7 @@ class HomeComponent(
onOpenChat: (PeerId, Boolean, Int) -> Unit,
onOpenProfile: (PeerId) -> Unit,
onOpenSettings: () -> Unit,
onOpenProxy: () -> Unit = {},
onOpenFolders: () -> Unit,
) : ComponentContext by componentContext {
val chats = ChatsComponent(
Expand All @@ -618,6 +621,7 @@ class HomeComponent(
onOpenProfile = onOpenProfile,
onOpenSelfProfile = { onOpenProfile(PeerId(0L)) },
onOpenSettings = onOpenSettings,
onOpenProxy = onOpenProxy,
onOpenFolders = onOpenFolders,
)
val folders = FoldersComponent(componentContext, storeFactory, client, warmup)
Expand Down
3 changes: 3 additions & 0 deletions core/common/src/main/java/org/monogram/core/common/AppLog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ object AppLog {
}

fun api(op: String, detail: String) {
DebugLog.ingestApi(op, detail)
runCatching { Log.i("$TAG.api", "$op ${redact(detail)}") }
}

fun warn(op: String, detail: String) {
DebugLog.ingestWarn(op, detail)
runCatching { Log.w("$TAG.api", "$op ${redact(detail)}") }
}

Expand All @@ -39,6 +41,7 @@ object AppLog {
append(" thread=").append(thread).append('\n')
append(redact(stackTrace(error)))
}
DebugLog.ingestCrash(thread, body)
Log.e("$TAG.crash", body)
runCatching {
val file = crashFile ?: return@runCatching
Expand Down
170 changes: 170 additions & 0 deletions core/common/src/main/java/org/monogram/core/common/DebugLog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package org.monogram.core.common

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.text.SimpleDateFormat
import java.util.ArrayDeque
import java.util.Date
import java.util.Locale

enum class DebugLogKind { API, RECOMPOSITION, MEDIA, CONNECTION, ERROR }

data class DebugLogEvent(
val id: Long,
val atEpochMs: Long,
val kind: DebugLogKind,
val summary: String,
val detail: String,
)

object DebugLog {
const val MAX_EVENTS = 2_000
private const val SUMMARY_LIMIT = 180
private const val DETAIL_LIMIT = 8_000

private val events = ArrayDeque<DebugLogEvent>()
private val lock = Any()
private var nextId = 1L
private val revision = MutableStateFlow(0L)

@Volatile
var enabled: Boolean = false
private set

val changes: StateFlow<Long> = revision

fun install(enabled: Boolean) {
this.enabled = enabled
if (!enabled) clear()
}

fun resetForTests(enabled: Boolean = true) {
this.enabled = enabled
synchronized(lock) {
events.clear()
nextId = 1L
}
revision.value = 0L
}

fun ingestApi(op: String, detail: String) {
if (!enabled) return
val safeDetail = redactBlock(detail)
add(DebugLogKind.API, oneLine(op, safeDetail, null), safeDetail)
}

fun ingestWarn(op: String, detail: String) {
if (!enabled) return
val safeDetail = redactBlock(detail)
add(DebugLogKind.ERROR, oneLine(op, safeDetail, null), safeDetail)
}

fun ingestCrash(thread: String, detail: String) {
if (!enabled) return
val safeDetail = redactBlock(detail)
add(DebugLogKind.ERROR, oneLine("crash", thread, null), safeDetail)
}

fun ingestPerf(op: String, detail: String, elapsedMs: Long?) {
if (!enabled) return
val safeDetail = redactBlock(detail)
val kind = classify(op, safeDetail)
add(kind, oneLine(op, safeDetail, elapsedMs), safeDetail)
}

fun query(kind: DebugLogKind?, text: String): List<DebugLogEvent> {
val needle = text.trim()
val newestFirst = synchronized(lock) { events.toList() }.asReversed()
if (kind == null && needle.isEmpty()) return newestFirst
return newestFirst.filter { event ->
(kind == null || event.kind == kind) &&
(needle.isEmpty() ||
event.summary.contains(needle, ignoreCase = true) ||
event.detail.contains(needle, ignoreCase = true))
}
}

fun clear() {
synchronized(lock) { events.clear() }
revision.value = revision.value + 1L
}

fun exportText(kind: DebugLogKind? = null, text: String = ""): String {
val format = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
return buildString {
query(kind, text).forEach { event ->
append(format.format(Date(event.atEpochMs)))
append(' ')
append(event.kind.name)
append(' ')
append(event.summary)
append('\n')
if (event.detail.isNotBlank() && event.detail != event.summary) {
append(event.detail)
append('\n')
}
}
}
}

internal fun classify(op: String, detail: String): DebugLogKind {
val subject = namedOp(detail) ?: op
val name = subject.lowercase(Locale.US)
if (name == "recomp" || name.startsWith("recomp")) return DebugLogKind.RECOMPOSITION
if (hasFailure(detail)) return DebugLogKind.ERROR
return when {
isMedia(name) -> DebugLogKind.MEDIA
isConnection(name) -> DebugLogKind.CONNECTION
else -> DebugLogKind.API
}
}

private fun add(kind: DebugLogKind, summary: String, detail: String) {
val row = DebugLogEvent(
id = 0L,
atEpochMs = System.currentTimeMillis(),
kind = kind,
summary = DebugStats.sanitize(summary).take(SUMMARY_LIMIT),
detail = detail.take(DETAIL_LIMIT),
)
synchronized(lock) {
val stored = row.copy(id = nextId++)
events.addLast(stored)
while (events.size > MAX_EVENTS) events.removeFirst()
}
revision.value = revision.value + 1L
}

private fun oneLine(op: String, detail: String, elapsedMs: Long?): String {
val body = detail.replace('\n', ' ').replace('\r', ' ').trim()
val head = buildString {
append(DebugStats.sanitize(op).ifBlank { "log" })
if (elapsedMs != null) append(' ').append(elapsedMs.coerceAtLeast(0L)).append("ms")
}
return if (body.isEmpty()) head else "$head $body"
}

private fun redactBlock(value: String): String =
value.lineSequence().joinToString("\n") { DebugStats.sanitize(it) }

private fun namedOp(detail: String): String? =
NAMED_OP.find(detail)?.groupValues?.get(1)

private fun hasFailure(detail: String): Boolean {
val result = RESULT.find(detail)?.groupValues?.get(1) ?: return false
return result != "ok" && result != "cancel"
}

private fun isMedia(name: String): Boolean =
name.startsWith("download:") ||
name.startsWith("stream") ||
name.startsWith("cache") ||
name.contains("upload") ||
name.contains("queue_wait")

private fun isConnection(name: String): Boolean =
name.contains("connect") || name.contains("prewarm")

private val NAMED_OP = Regex("(?:^|\\s)op=(\\S+)")
private val RESULT = Regex("(?:^|\\s)result=(\\S+)")
}
Loading