diff --git a/.gitignore b/.gitignore index 635fdbb3c..aaa1772e6 100644 --- a/.gitignore +++ b/.gitignore @@ -425,6 +425,7 @@ default.profraw /Backend/BarkFluff.Web/wwwroot/legal/ /Android/.idea/caches +/Android/tools/perf/out/ # Docker local environment: secrets and host-mounted data docker/**/.env diff --git a/Android/Barkfluff.Client.Android/app/build.gradle.kts b/Android/Barkfluff.Client.Android/app/build.gradle.kts index 81d88fae8..4eb9a3d4b 100644 --- a/Android/Barkfluff.Client.Android/app/build.gradle.kts +++ b/Android/Barkfluff.Client.Android/app/build.gradle.kts @@ -167,6 +167,12 @@ android { "proguard-rules.pro" ) } + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + isDebuggable = false + } } compileOptions { // libsignal-android 0.86+ использует Java records (Java 16+) и требует @@ -327,3 +333,13 @@ tasks.withType().configureEach { rootProject.file("tools/check_android_ui.py").absolutePath, ) } + +tasks.configureEach { + if ((name.startsWith("install") || name.startsWith("uninstall")) && name.endsWith("Benchmark")) { + doFirst { + check(!System.getenv("ANDROID_SERIAL").isNullOrBlank()) { + "Refusing to run $name without ANDROID_SERIAL. Use a verified benchmark device." + } + } + } +} diff --git a/Android/Barkfluff.Client.Android/app/src/benchmark/AndroidManifest.xml b/Android/Barkfluff.Client.Android/app/src/benchmark/AndroidManifest.xml new file mode 100644 index 000000000..96aad3409 --- /dev/null +++ b/Android/Barkfluff.Client.Android/app/src/benchmark/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/BarkFluffApplication.kt b/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/BarkFluffApplication.kt index cf3ebfbf9..e33aa62a8 100644 --- a/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/BarkFluffApplication.kt +++ b/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/BarkFluffApplication.kt @@ -6,6 +6,8 @@ import android.content.Intent import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.os.StrictMode +import android.os.Trace import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -28,6 +30,7 @@ import com.barkfluff.client.domain.media.AutoDownloadNetworkState import com.barkfluff.client.grpc.GrpcClientRegistry import com.barkfluff.client.grpc.RealtimeService import com.barkfluff.client.notifications.NotificationHelper +import com.barkfluff.client.perf.PerfTrace import com.barkfluff.client.repository.ChatRepository import com.barkfluff.client.send.OutgoingMessageQueue import com.barkfluff.client.repository.PrivateChatRepository @@ -139,7 +142,18 @@ class BarkFluffApplication : Application() { var pendingDeepLink: android.net.Uri? = null override fun onCreate() { - super.onCreate() + if (BuildConfig.DEBUG) enableStrictMode() + PerfTrace.beginAppToChats() + Trace.beginSection(PerfTrace.APPLICATION_ON_CREATE) + try { + super.onCreate() + onApplicationCreate() + } finally { + Trace.endSection() + } + } + + private fun onApplicationCreate() { applicationScope.launch(Dispatchers.IO) { try { prekeyManager.retryPendingCleanup() @@ -209,6 +223,28 @@ class BarkFluffApplication : Application() { }) } + private fun enableStrictMode() { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .detectCustomSlowCalls() + .detectResourceMismatches() + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedClosableObjects() + .detectLeakedSqlLiteObjects() + .detectActivityLeaks() + .detectLeakedRegistrationObjects() + .penaltyLog() + .build() + ) + } + override fun onTerminate() { audioPlayback.stop() connectivityManager.unregisterNetworkCallback(networkCallback) diff --git a/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatActivity.kt b/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatActivity.kt index e9cfd413a..c606d2497 100644 --- a/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatActivity.kt +++ b/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatActivity.kt @@ -21,6 +21,7 @@ import com.barkfluff.client.voice.VoiceRecordingController import com.barkfluff.client.voice.VoiceRecordingMode import com.barkfluff.client.voice.VoiceRecordingState import com.barkfluff.client.voice.VoiceDraftPreview +import com.barkfluff.client.perf.PerfTrace import android.net.Uri import android.os.Bundle import android.os.Environment @@ -137,6 +138,8 @@ class ChatActivity : AppCompatActivity() { private val viewModel: ChatViewModel by viewModels() private var submittedItemsVersion = 0L private var committedItemsVersion = 0L + private var chatOpenTracePending = false + private var chatOpenTraceEnded = false private var scrollToBottomJob: Job? = null private lateinit var globalParam: GlobalParam @@ -359,6 +362,7 @@ class ChatActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + chatOpenTracePending = intent.getBooleanExtra(PerfTrace.EXTRA_CHAT_OPEN, false) binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) messageActionsOverlay = MessageActionsOverlay(binding.chatRootLayout) @@ -478,6 +482,10 @@ class ChatActivity : AppCompatActivity() { val version = ++submittedItemsVersion messageAdapter.submitList(messageRowProjector.project(state.items)) { if (version != submittedItemsVersion) return@submitList + if (chatOpenTracePending && !chatOpenTraceEnded && messageAdapter.currentList.isNotEmpty()) { + PerfTrace.endChatOpen() + chatOpenTraceEnded = true + } committedItemsVersion = version if (applyMessageNavigation(listIsCommitted = true)) { // A target has priority while its window is loading or committing. @@ -3027,6 +3035,10 @@ class ChatActivity : AppCompatActivity() { } override fun onDestroy() { + if (chatOpenTracePending && !chatOpenTraceEnded) { + PerfTrace.endChatOpen() + chatOpenTraceEnded = true + } binding.messagesRecyclerView.adapter = null super.onDestroy() // Сбрасываем открытый чат diff --git a/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatsFragment.kt b/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatsFragment.kt index e0f50dff1..0055b65ef 100644 --- a/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatsFragment.kt +++ b/Android/Barkfluff.Client.Android/app/src/main/java/com/barkfluff/client/ChatsFragment.kt @@ -26,6 +26,7 @@ import com.barkfluff.client.data.OpenChatManager import com.barkfluff.client.databinding.FragmentChatsBinding import com.barkfluff.client.domain.model.ChatSummary import com.barkfluff.client.domain.gateway.FileMediaGateway +import com.barkfluff.client.perf.PerfTrace import com.barkfluff.client.utils.AvatarLoader import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -115,7 +116,13 @@ class ChatsFragment : Fragment() { if (binding.chatRecyclerView.adapter !== chatAdapter) { binding.chatRecyclerView.adapter = chatAdapter } - chatAdapter.submitList(state.displayItems) + val hasRealChat = state.displayItems.any { !it.isFooter } + chatAdapter.submitList(state.displayItems) { + if (hasRealChat && isAdded && PerfTrace.markFirstChatDisplayed()) { + PerfTrace.endAppToChats() + requireActivity().reportFullyDrawn() + } + } showEmptyState(state.displayItems.isEmpty()) if (initialScrollPending && state.displayItems.isNotEmpty()) { initialScrollPending = false @@ -475,6 +482,7 @@ class ChatsFragment : Fragment() { } private fun onChatClicked(chat: ChatSummary) { + PerfTrace.beginChatOpen() // Находим display item для получения дополнительной информации val displayItem = chatAdapter.currentList.find { !it.isFooter && it.chatData.id == chat.id } @@ -485,7 +493,7 @@ class ChatsFragment : Fragment() { title = displayItem?.displayTitle ?: chat.title.ifBlank { getString(R.string.create_chat_private) }, inviteState = chat.privateInviteState.number, inviterUserId = chat.privateInviterUserId - )) + ).putExtra(PerfTrace.EXTRA_CHAT_OPEN, true)) return } @@ -495,6 +503,7 @@ class ChatsFragment : Fragment() { putExtra("chat_avatar_file_id", displayItem?.displayAvatarFileId ?: chat.pictureFileId.ifBlank { null }) putExtra("is_group_chat", chat.isGroupChat) putExtra("other_user_id", displayItem?.otherUserId ?: 0L) + putExtra(PerfTrace.EXTRA_CHAT_OPEN, true) } // Устанавливаем чат как открытый перед запуском Activity diff --git a/Android/benchmark/build.gradle.kts b/Android/benchmark/build.gradle.kts new file mode 100644 index 000000000..99ede6c07 --- /dev/null +++ b/Android/benchmark/build.gradle.kts @@ -0,0 +1,56 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.barkfluff.benchmark" + compileSdk = 36 + + defaultConfig { + minSdk = 31 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + missingDimensionStrategy("channel", "nightly") + } + + targetProjectPath = ":app-v1" + experimentalProperties["android.experimental.self-instrumenting"] = true + + buildTypes { + create("benchmark") { + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.androidx.benchmark.macro.junit4) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.junit) +} + +tasks.configureEach { + if (name.startsWith("connected") || name.startsWith("install") || name.startsWith("uninstall")) { + doFirst { + check(!System.getenv("ANDROID_SERIAL").isNullOrBlank()) { + "Refusing to run $name without ANDROID_SERIAL. Use a verified benchmark device." + } + } + } +} diff --git a/Android/benchmark/src/main/AndroidManifest.xml b/Android/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2f41b44e0 --- /dev/null +++ b/Android/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Android/benchmark/src/main/java/com/barkfluff/benchmark/BenchmarkTestSupport.kt b/Android/benchmark/src/main/java/com/barkfluff/benchmark/BenchmarkTestSupport.kt new file mode 100644 index 000000000..91e430ece --- /dev/null +++ b/Android/benchmark/src/main/java/com/barkfluff/benchmark/BenchmarkTestSupport.kt @@ -0,0 +1,69 @@ +package com.barkfluff.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.BaselineProfileMode +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.test.uiautomator.UiObject2 + +internal const val TARGET_PACKAGE = "com.barkfluff.nightly" +internal const val UI_WAIT_TIMEOUT_MS = 10_000L +internal const val UI_POLL_INTERVAL_MS = 100L +private const val UI_STATE_CHECK_TIMEOUT_MS = 250L + +internal object BenchmarkCompilationModes { + fun parameters(): Collection> = listOf( + arrayOf(CompilationMode.None()), + arrayOf( + CompilationMode.Partial( + baselineProfileMode = BaselineProfileMode.Disable, + warmupIterations = 3, + ) + ), + ) +} + +internal fun MacrobenchmarkScope.waitForChatList() { + val chatTitle = onElementOrNull( + timeoutMs = UI_WAIT_TIMEOUT_MS, + pollIntervalMs = UI_POLL_INTERVAL_MS, + ) { + viewIdResourceName == "$TARGET_PACKAGE:id/chatTitle" + } + check(chatTitle != null) { + "Не найден список чатов. Залогиньтесь в тестовый аккаунт в benchmark-сборке " + + "и выдайте POST_NOTIFICATIONS." + } +} + +internal fun MacrobenchmarkScope.ensureChatList() { + when { + hasVisibleView("$TARGET_PACKAGE:id/chatTitle") -> return + hasVisibleView("$TARGET_PACKAGE:id/messagesRecyclerView") -> pressBack() + else -> { + pressHome() + startActivityAndWait() + } + } + waitForChatList() +} + +private fun MacrobenchmarkScope.hasVisibleView(resourceName: String): Boolean = onElementOrNull( + timeoutMs = UI_STATE_CHECK_TIMEOUT_MS, + pollIntervalMs = UI_POLL_INTERVAL_MS, +) { + viewIdResourceName == resourceName +} != null + +internal fun MacrobenchmarkScope.firstChatCard(): UiObject2 = onElementOrNull( + timeoutMs = UI_WAIT_TIMEOUT_MS, + pollIntervalMs = UI_POLL_INTERVAL_MS, +) { + viewIdResourceName == "$TARGET_PACKAGE:id/chatCard" +} ?: error("Не найден chatCard в списке чатов тестового аккаунта.") + +internal fun MacrobenchmarkScope.waitForMessages(): UiObject2 = onElementOrNull( + timeoutMs = UI_WAIT_TIMEOUT_MS, + pollIntervalMs = UI_POLL_INTERVAL_MS, +) { + viewIdResourceName == "$TARGET_PACKAGE:id/messagesRecyclerView" && childCount > 0 +} ?: error("Не появились сообщения в messagesRecyclerView за ${UI_WAIT_TIMEOUT_MS}ms.") diff --git a/Android/benchmark/src/main/java/com/barkfluff/benchmark/ChatOpenBenchmark.kt b/Android/benchmark/src/main/java/com/barkfluff/benchmark/ChatOpenBenchmark.kt new file mode 100644 index 000000000..4c2609c8f --- /dev/null +++ b/Android/benchmark/src/main/java/com/barkfluff/benchmark/ChatOpenBenchmark.kt @@ -0,0 +1,43 @@ +package com.barkfluff.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class ChatOpenBenchmark(private val compilationMode: CompilationMode) { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @OptIn(ExperimentalMetricApi::class) + @Test + fun openFirstChat() { + benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf( + TraceSectionMetric("BF:chatOpen", TraceSectionMetric.Mode.First), + FrameTimingMetric(), + ), + compilationMode = compilationMode, + iterations = 10, + setupBlock = { + ensureChatList() + }, + ) { + firstChatCard().click() + waitForMessages() + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun compilationModes(): Collection> = BenchmarkCompilationModes.parameters() + } +} diff --git a/Android/benchmark/src/main/java/com/barkfluff/benchmark/ChatScrollBenchmark.kt b/Android/benchmark/src/main/java/com/barkfluff/benchmark/ChatScrollBenchmark.kt new file mode 100644 index 000000000..017aab407 --- /dev/null +++ b/Android/benchmark/src/main/java/com/barkfluff/benchmark/ChatScrollBenchmark.kt @@ -0,0 +1,71 @@ +package com.barkfluff.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class ChatScrollBenchmark(private val compilationMode: CompilationMode) { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @OptIn(ExperimentalMetricApi::class) + @Test + fun scrollFirstChat() { + benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf( + FrameTimingMetric(), + TraceSectionMetric("RV onBindViewHolder%", TraceSectionMetric.Mode.Sum), + TraceSectionMetric("RV onCreateViewHolder%", TraceSectionMetric.Mode.Sum), + ), + compilationMode = compilationMode, + iterations = 5, + setupBlock = { + ensureChatList() + firstChatCard().click() + waitForMessages() + }, + ) { + val messages = waitForMessages() + val listBounds = messages.visibleBounds + val headerBounds = device.findObject(By.res("$TARGET_PACKAGE:id/chatHeaderBar"))?.visibleBounds + val inputBounds = device.findObject(By.res("$TARGET_PACKAGE:id/inputBar"))?.visibleBounds + val gestureMargins = if (headerBounds != null && inputBounds != null) { + val top = headerBounds.bottom - listBounds.top + 1 + val bottom = listBounds.bottom - inputBounds.top + 1 + if (top >= 0 && bottom >= 0 && top + bottom < listBounds.height()) top to bottom else null + } else { + null + } + val flingMessages: (Direction) -> Unit = { direction -> + waitForMessages().apply { + if (gestureMargins != null) { + setGestureMargins(0, gestureMargins.first, 0, gestureMargins.second) + } else { + setGestureMarginPercentage(0.15f) + } + }.fling(direction) + } + + // UP reveals older messages above; stackFromEnd=true anchors the bottom but does not reverse item order. + repeat(4) { flingMessages(Direction.UP) } + repeat(4) { flingMessages(Direction.DOWN) } + device.waitForIdle(5_000L) + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun compilationModes(): Collection> = BenchmarkCompilationModes.parameters() + } +} diff --git a/Android/benchmark/src/main/java/com/barkfluff/benchmark/StartupBenchmark.kt b/Android/benchmark/src/main/java/com/barkfluff/benchmark/StartupBenchmark.kt new file mode 100644 index 000000000..038bd49f0 --- /dev/null +++ b/Android/benchmark/src/main/java/com/barkfluff/benchmark/StartupBenchmark.kt @@ -0,0 +1,42 @@ +package com.barkfluff.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class StartupBenchmark(private val compilationMode: CompilationMode) { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @OptIn(ExperimentalMetricApi::class) + @Test + fun coldStartup() = benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf( + StartupTimingMetric(), + TraceSectionMetric("BF:appToChats", TraceSectionMetric.Mode.First), + TraceSectionMetric("BF:Application.onCreate", TraceSectionMetric.Mode.First), + ), + compilationMode = compilationMode, + startupMode = StartupMode.COLD, + iterations = 10, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + waitForChatList() + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun compilationModes(): Collection> = BenchmarkCompilationModes.parameters() + } +} diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts index d42b3f4c0..7b000209e 100644 --- a/Android/build.gradle.kts +++ b/Android/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("com.google.gms.google-services") version "4.4.4" apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.test) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false diff --git a/Android/core/src/main/java/com/barkfluff/client/grpc/RealtimeService.kt b/Android/core/src/main/java/com/barkfluff/client/grpc/RealtimeService.kt index 4639c1f3c..a1a81c7a6 100644 --- a/Android/core/src/main/java/com/barkfluff/client/grpc/RealtimeService.kt +++ b/Android/core/src/main/java/com/barkfluff/client/grpc/RealtimeService.kt @@ -8,6 +8,7 @@ import barkfluff.onliner.OnlinerApiOuterClass import barkfluff.updates.UpdatesApiOuterClass import com.barkfluff.client.data.GlobalParam import com.barkfluff.client.crypto.CryptoStoreScope +import com.barkfluff.client.perf.PerfTrace import io.grpc.Status import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.* @@ -561,7 +562,12 @@ class RealtimeService( val client = clientRegistry.onlinerClient if (client != null) { val request = OnlinerApiOuterClass.SetOnlineStatusRequest.getDefaultInstance() - client.setOnlineStatus(request) + val cookie = PerfTrace.beginOnlinePing() + try { + client.setOnlineStatus(request) + } finally { + PerfTrace.endOnlinePing(cookie) + } } } catch (e: Exception) { Log.w(TAG, "Online ping failed: ${e.message}") diff --git a/Android/core/src/main/java/com/barkfluff/client/perf/PerfTrace.kt b/Android/core/src/main/java/com/barkfluff/client/perf/PerfTrace.kt new file mode 100644 index 000000000..16b02c248 --- /dev/null +++ b/Android/core/src/main/java/com/barkfluff/client/perf/PerfTrace.kt @@ -0,0 +1,46 @@ +package com.barkfluff.client.perf + +import android.os.Trace +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +object PerfTrace { + const val APP_TO_CHATS = "BF:appToChats" + const val APP_TO_CHATS_COOKIE = 0 + const val APPLICATION_ON_CREATE = "BF:Application.onCreate" + const val CHAT_OPEN = "BF:chatOpen" + const val CHAT_OPEN_COOKIE = 1 + const val ONLINE_PING = "BF:onlinePing" + const val EXTRA_CHAT_OPEN = "com.barkfluff.client.perf.CHAT_OPEN" + + private val appToChatsReported = AtomicBoolean() + private val onlinePingCookie = AtomicInteger() + + fun beginAppToChats() { + Trace.beginAsyncSection(APP_TO_CHATS, APP_TO_CHATS_COOKIE) + } + + fun markFirstChatDisplayed(): Boolean = appToChatsReported.compareAndSet(false, true) + + fun endAppToChats() { + Trace.endAsyncSection(APP_TO_CHATS, APP_TO_CHATS_COOKIE) + } + + fun beginChatOpen() { + Trace.beginAsyncSection(CHAT_OPEN, CHAT_OPEN_COOKIE) + } + + fun endChatOpen() { + Trace.endAsyncSection(CHAT_OPEN, CHAT_OPEN_COOKIE) + } + + fun beginOnlinePing(): Int { + val cookie = onlinePingCookie.incrementAndGet() + Trace.beginAsyncSection(ONLINE_PING, cookie) + return cookie + } + + fun endOnlinePing(cookie: Int) { + Trace.endAsyncSection(ONLINE_PING, cookie) + } +} diff --git a/Android/gradle/libs.versions.toml b/Android/gradle/libs.versions.toml index 961d0fb90..02a5120a5 100644 --- a/Android/gradle/libs.versions.toml +++ b/Android/gradle/libs.versions.toml @@ -23,11 +23,15 @@ composeBom = "2026.04.01" activityCompose = "1.9.3" lifecycleViewmodelCompose = "2.8.7" coroutinesTest = "1.7.3" +androidxBenchmark = "1.5.0" +uiautomator = "2.4.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxBenchmark" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -59,6 +63,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts index 9692af210..ecfaf71a9 100644 --- a/Android/settings.gradle.kts +++ b/Android/settings.gradle.kts @@ -35,3 +35,5 @@ include(":core") // V1 (Views/XML) — физически остаётся в своей папке. include(":app-v1") project(":app-v1").projectDir = file("Barkfluff.Client.Android/app") + +include(":benchmark") diff --git a/Android/tools/perf/capture_power.py b/Android/tools/perf/capture_power.py new file mode 100644 index 000000000..c77c705c4 --- /dev/null +++ b/Android/tools/perf/capture_power.py @@ -0,0 +1,388 @@ +"""Записать энергозамер и при необходимости разобрать Perfetto-трейс.""" + +from __future__ import annotations + +import argparse +import csv +import io +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from perf_device import ADB_COMMAND, PerfDeviceError, run_adb, verify_device + + +PERF_DIR = Path(__file__).resolve().parent +OUTPUT_ROOT = PERF_DIR / "out" +TRACE_CONFIG = PERF_DIR / "power_trace.pbtx" +QUERY_DIR = PERF_DIR / "queries" +DEFAULT_PACKAGE = "com.barkfluff.nightly" +REMOTE_TRACE_DIR = "/data/misc/perfetto-traces" +SCREEN_OFF_TIMEOUT_MAX = "2147483647" + + +class TraceProcessorError(RuntimeError): + """Ошибка запуска или разбора результата trace_processor_shell.""" + + +def parse_csv_rows(output: str, *, required_columns: set[str]) -> list[dict[str, str]]: + """Разобрать CSV trace processor, проверяя заголовок и строки.""" + try: + reader = csv.DictReader(io.StringIO(output.strip())) + if not reader.fieldnames or not required_columns.issubset(reader.fieldnames): + raise ValueError("CSV не содержит ожидаемые столбцы.") + rows = list(reader) + if any(None in row or any(value is None for value in row.values()) for row in rows): + raise ValueError("CSV содержит строку с неправильным числом столбцов.") + return rows + except (csv.Error, ValueError) as error: + raise TraceProcessorError(f"Не удалось разобрать CSV trace processor: {error}") from error + + +def parse_data_loss(output: str) -> list[dict[str, str]]: + return parse_csv_rows( + output, + required_columns={"name", "idx", "severity", "source", "value"}, + ) + + +def parse_trace_bounds(output: str) -> tuple[int, int]: + rows = parse_csv_rows(output, required_columns={"start_ts", "end_ts"}) + if len(rows) != 1: + raise TraceProcessorError("Ожидалась одна строка trace_bounds.") + try: + start_ns = int(rows[0]["start_ts"]) + end_ns = int(rows[0]["end_ts"]) + except (TypeError, ValueError) as error: + raise TraceProcessorError("trace_bounds вернул некорректные timestamps.") from error + if end_ns < start_ns: + raise TraceProcessorError("Конец трейса раньше начала.") + return start_ns, end_ns + + +def parse_rails(output: str) -> list[dict[str, str | float | None]]: + rows = parse_csv_rows(output, required_columns={"rail", "energy_uws"}) + result: list[dict[str, str | float | None]] = [] + for row in rows: + try: + energy = float(row["energy_uws"]) + except (TypeError, ValueError) as error: + raise TraceProcessorError("Некорректное значение energy_uws.") from error + result.append({"rail": row["rail"], "energy_uws": energy}) + return result + + +def parse_pings(output: str) -> dict[str, int | float | None]: + rows = parse_csv_rows( + output, + required_columns={"completed_count", "mean_duration_ms", "unfinished_count"}, + ) + if len(rows) != 1: + raise TraceProcessorError("Ожидалась одна строка метрик BF:onlinePing.") + try: + completed = int(rows[0]["completed_count"] or 0) + mean_duration = ( + float(rows[0]["mean_duration_ms"]) + if rows[0]["mean_duration_ms"] not in (None, "") + else None + ) + unfinished = int(rows[0]["unfinished_count"] or 0) + except (TypeError, ValueError) as error: + raise TraceProcessorError("Некорректные метрики BF:onlinePing.") from error + return { + "completed_count": completed, + "mean_duration_ms": mean_duration, + "unfinished_count": unfinished, + } + + +def require_trace_processor_success( + returncode: int, + stdout: str, + stderr: str, +) -> str: + if returncode != 0: + detail = stderr.strip() or stdout.strip() or "нет вывода" + raise TraceProcessorError( + f"trace_processor завершился с кодом {returncode}: {detail}" + ) + return stdout + + +def run_trace_query( + executable: Path, + trace_path: Path, + query_path: Path, + *, + start_ns: int | None = None, + end_ns: int | None = None, +) -> str: + query = query_path.read_text(encoding="utf-8") + if start_ns is not None: + query = query.replace("{{START_NS}}", str(start_ns)) + if end_ns is not None: + query = query.replace("{{END_NS}}", str(end_ns)) + if "{{START_NS}}" in query or "{{END_NS}}" in query: + raise TraceProcessorError(f"Не подставлены границы окна в {query_path.name}.") + + generated_query = trace_path.parent / f".{query_path.stem}.sql" + generated_query.write_text(query, encoding="utf-8") + try: + result = subprocess.run( + [str(executable), "query", "-f", str(generated_query), str(trace_path)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + except OSError as error: + raise TraceProcessorError(f"Не удалось запустить trace_processor: {error}") from error + finally: + generated_query.unlink(missing_ok=True) + return require_trace_processor_success(result.returncode, result.stdout, result.stderr) + + +def analyze_trace( + executable: Path, + trace_path: Path, + *, + warmup_sec: int, + minutes: int, +) -> dict[str, Any]: + data_loss = parse_data_loss( + run_trace_query(executable, trace_path, QUERY_DIR / "data_loss.sql") + ) + trace_start_ns, trace_end_ns = parse_trace_bounds( + run_trace_query(executable, trace_path, QUERY_DIR / "trace_bounds.sql") + ) + window_start_ns = trace_start_ns + warmup_sec * 1_000_000_000 + window_end_ns = window_start_ns + minutes * 60 * 1_000_000_000 + if window_end_ns > trace_end_ns: + raise TraceProcessorError( + "Трейс короче окна анализа: " + f"нужно {minutes * 60} секунд после прогрева." + ) + + rails = parse_rails( + run_trace_query( + executable, + trace_path, + QUERY_DIR / "rails.sql", + start_ns=window_start_ns, + end_ns=window_end_ns, + ) + ) + pings = parse_pings( + run_trace_query( + executable, + trace_path, + QUERY_DIR / "pings.sql", + start_ns=window_start_ns, + end_ns=window_end_ns, + ) + ) + return { + "status": "FAIL" if data_loss else "PASS", + "window": { + "basis": "trace_bounds.start_ts", + "start_ns": window_start_ns, + "end_ns": window_end_ns, + "duration_seconds": minutes * 60, + "warmup_seconds": warmup_sec, + }, + "data_loss": data_loss, + "power_rails": rails, + "online_ping": pings, + } + + +def save_summary(output_dir: Path, summary: dict[str, Any]) -> None: + (output_dir / "summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def restore_device_settings( + serial: str, + previous_timeout: str, + *, + adb_command: tuple[str, ...], + restore_timeout: bool, + restore_stayon: bool, +) -> None: + errors: list[str] = [] + if restore_timeout: + command = ( + ("shell", "settings", "delete", "system", "screen_off_timeout") + if previous_timeout == "null" + else ("shell", "settings", "put", "system", "screen_off_timeout", previous_timeout) + ) + try: + run_adb(serial, *command, adb_command=adb_command) + except (PerfDeviceError, OSError, subprocess.TimeoutExpired) as error: + errors.append(str(error)) + if restore_stayon: + try: + run_adb(serial, "shell", "svc", "power", "stayon", "false", adb_command=adb_command) + except (PerfDeviceError, OSError, subprocess.TimeoutExpired) as error: + errors.append(str(error)) + if errors: + raise PerfDeviceError("Не удалось восстановить настройки устройства: " + "; ".join(errors)) + + +def capture_power( + serial: str, + expect_model: str, + *, + warmup_sec: int = 60, + minutes: int = 10, + package: str = DEFAULT_PACKAGE, + trace_processor: Path | None = None, + adb_command: tuple[str, ...] = ADB_COMMAND, +) -> int: + model = verify_device(serial, expect_model, adb_command=adb_command) + if trace_processor is not None and not trace_processor.is_file(): + raise TraceProcessorError(f"Файл trace_processor не найден: {trace_processor}") + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + output_dir = OUTPUT_ROOT / timestamp + output_dir.mkdir(parents=True, exist_ok=False) + trace_path = output_dir / "power.perfetto-trace" + remote_trace = f"{REMOTE_TRACE_DIR}/barkfluff-power-{timestamp}.perfetto-trace" + duration_ms = (warmup_sec + minutes * 60 + 30) * 1000 + config = TRACE_CONFIG.read_text(encoding="utf-8").replace( + "{{DURATION_MS}}", str(duration_ms) + ) + + previous_timeout = run_adb( + serial, + "shell", + "settings", + "get", + "system", + "screen_off_timeout", + adb_command=adb_command, + ).stdout.strip() + timeout_change_attempted = False + stayon_change_attempted = False + trace_pulled = False + summary: dict[str, Any] = { + "device": {"serial": serial, "model": model}, + "package": package, + "trace": str(trace_path), + "warmup_seconds": warmup_sec, + "analysis_seconds": minutes * 60, + "trace_duration_seconds": duration_ms // 1000, + "analysis": {"status": "NOT_RUN", "reason": "trace_processor не задан"}, + } + + try: + timeout_change_attempted = True + run_adb( + serial, + "shell", + "settings", + "put", + "system", + "screen_off_timeout", + SCREEN_OFF_TIMEOUT_MAX, + adb_command=adb_command, + ) + stayon_change_attempted = True + run_adb(serial, "shell", "svc", "power", "stayon", "usb", adb_command=adb_command) + run_adb(serial, "shell", "monkey", "-p", package, "1", adb_command=adb_command) + run_adb( + serial, + "shell", + "perfetto", + "--txt", + "-c", + "-", + "-o", + remote_trace, + adb_command=adb_command, + input_text=config, + timeout=duration_ms // 1000 + 120, + ) + run_adb(serial, "pull", remote_trace, str(trace_path), adb_command=adb_command) + trace_pulled = True + batterystats = run_adb( + serial, + "shell", + "dumpsys", + "batterystats", + package, + adb_command=adb_command, + ) + (output_dir / "batterystats.txt").write_text( + batterystats.stdout, + encoding="utf-8", + ) + finally: + restore_device_settings( + serial, + previous_timeout, + adb_command=adb_command, + restore_timeout=timeout_change_attempted, + restore_stayon=stayon_change_attempted, + ) + + if trace_processor is not None and trace_pulled: + try: + summary["analysis"] = analyze_trace( + trace_processor, + trace_path, + warmup_sec=warmup_sec, + minutes=minutes, + ) + except TraceProcessorError as error: + summary["analysis"] = {"status": "FAIL", "error": str(error)} + save_summary(output_dir, summary) + print(f"Ошибка анализа трейса: {error}", file=sys.stderr) + return 1 + + save_summary(output_dir, summary) + print(f"Результаты сохранены: {output_dir}") + if summary["analysis"].get("status") == "FAIL": + print("В трейсе найдены потери данных или ошибки.", file=sys.stderr) + return 1 + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", required=True, help="Явный adb serial устройства") + parser.add_argument("--expect-model", required=True, help='Ожидаемая модель, например "Pixel 10 Pro"') + parser.add_argument("--warmup-sec", type=int, default=60) + parser.add_argument("--minutes", type=int, default=10) + parser.add_argument("--package", default=DEFAULT_PACKAGE) + parser.add_argument("--trace-processor", type=Path, help="Путь к trace_processor_shell(.exe)") + return parser + + +def main() -> int: + args = build_parser().parse_args() + if args.warmup_sec < 0 or args.minutes <= 0: + print("warmup-sec должен быть >= 0, а minutes — > 0.", file=sys.stderr) + return 2 + try: + return capture_power( + args.serial, + args.expect_model, + warmup_sec=args.warmup_sec, + minutes=args.minutes, + package=args.package, + trace_processor=args.trace_processor, + ) + except (PerfDeviceError, TraceProcessorError, OSError, subprocess.TimeoutExpired) as error: + print(f"Ошибка энергозамера: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Android/tools/perf/perf_device.py b/Android/tools/perf/perf_device.py new file mode 100644 index 000000000..80493f926 --- /dev/null +++ b/Android/tools/perf/perf_device.py @@ -0,0 +1,80 @@ +"""Общие проверки устройства и безопасный вызов adb для замеров Android.""" + +from __future__ import annotations + +import subprocess +from collections.abc import Sequence + + +ADB_COMMAND = ("adb",) + + +class PerfDeviceError(RuntimeError): + """Ошибка проверки или команды устройства.""" + + +def run_adb( + serial: str, + *args: str, + adb_command: Sequence[str] = ADB_COMMAND, + input_text: str | None = None, + timeout: float | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """Выполнить adb только для явно указанного serial.""" + if not serial or not serial.strip(): + raise PerfDeviceError("Укажите serial устройства через --serial.") + if not adb_command: + raise PerfDeviceError("Не задан исполняемый файл adb.") + + command = [*adb_command, "-s", serial, *args] + result = subprocess.run( + command, + input=input_text, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + if check and result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + raise PerfDeviceError( + f"Команда adb завершилась с кодом {result.returncode}: " + f"{' '.join(command[1:])}\n{details}" + ) + return result + + +def verify_device( + serial: str | None, + expect_model: str | None, + *, + adb_command: Sequence[str] = ADB_COMMAND, +) -> str: + """Проверить serial и модель до выполнения команд, меняющих устройство.""" + if not serial or not serial.strip(): + raise PerfDeviceError("Укажите serial устройства через --serial.") + if not expect_model or not expect_model.strip(): + raise PerfDeviceError("Укажите ожидаемую модель через --expect-model.") + + state = run_adb(serial, "get-state", adb_command=adb_command).stdout.strip() + if state != "device": + raise PerfDeviceError(f"Устройство {serial} недоступно (состояние: {state or 'пустое'}).") + + model = run_adb( + serial, + "shell", + "getprop", + "ro.product.model", + adb_command=adb_command, + ).stdout.strip() + if not model: + raise PerfDeviceError(f"Не удалось определить модель устройства {serial}.") + if model.casefold() != expect_model.strip().casefold(): + raise PerfDeviceError( + f"Модель устройства не совпала: ожидалась {expect_model!r}, " + f"получена {model!r}. Команды замера не запускались." + ) + return model diff --git a/Android/tools/perf/power_trace.pbtx b/Android/tools/perf/power_trace.pbtx new file mode 100644 index 000000000..96ffaad10 --- /dev/null +++ b/Android/tools/perf/power_trace.pbtx @@ -0,0 +1,71 @@ +# Длительность задаёт capture_power.py перед запуском Perfetto. +duration_ms: {{DURATION_MS}} +write_into_file: true +file_write_period_ms: 2500 +flush_period_ms: 10000 + +# Основной буфер для ftrace и atrace. +buffers { + size_kb: 32768 + fill_policy: RING_BUFFER +} + +# Отдельный небольшой буфер для питания и статистики процессов. +buffers { + size_kb: 2048 + fill_policy: RING_BUFFER +} + +buffers { + size_kb: 2048 + fill_policy: RING_BUFFER +} + +data_sources { + config { + name: "android.power" + target_buffer: 1 + android_power_config { + collect_power_rails: true + battery_poll_ms: 1000 + battery_counters: BATTERY_COUNTER_CHARGE + battery_counters: BATTERY_COUNTER_CAPACITY_PERCENT + battery_counters: BATTERY_COUNTER_CURRENT + battery_counters: BATTERY_COUNTER_CURRENT_AVG + battery_counters: BATTERY_COUNTER_VOLTAGE + } + } +} + +data_sources { + config { + name: "linux.ftrace" + target_buffer: 0 + ftrace_config { + ftrace_events: "power/cpu_frequency" + ftrace_events: "power/suspend_resume" + atrace_categories: "am" + atrace_categories: "wm" + atrace_categories: "view" + atrace_apps: "com.barkfluff.nightly" + } + } +} + +data_sources { + config { + name: "linux.process_stats" + target_buffer: 2 + process_stats_config { + proc_stats_poll_ms: 5000 + scan_all_processes_on_start: true + } + } +} + +data_sources { + config { + name: "android.network_packets" + target_buffer: 0 + } +} diff --git a/Android/tools/perf/queries/data_loss.sql b/Android/tools/perf/queries/data_loss.sql new file mode 100644 index 000000000..0084d4560 --- /dev/null +++ b/Android/tools/perf/queries/data_loss.sql @@ -0,0 +1,3 @@ +SELECT name, idx, severity, source, value +FROM stats +WHERE (severity IN ('data_loss', 'error') AND value != 0); diff --git a/Android/tools/perf/queries/pings.sql b/Android/tools/perf/queries/pings.sql new file mode 100644 index 000000000..97dc492b1 --- /dev/null +++ b/Android/tools/perf/queries/pings.sql @@ -0,0 +1,8 @@ +SELECT + SUM(CASE WHEN dur >= 0 THEN 1 ELSE 0 END) AS completed_count, + AVG(CASE WHEN dur >= 0 THEN CAST(dur AS DOUBLE) / 1000000 END) AS mean_duration_ms, + SUM(CASE WHEN dur = -1 THEN 1 ELSE 0 END) AS unfinished_count +FROM slice +WHERE name = 'BF:onlinePing' + AND ts >= {{START_NS}} + AND ts < {{END_NS}}; diff --git a/Android/tools/perf/queries/rails.sql b/Android/tools/perf/queries/rails.sql new file mode 100644 index 000000000..e9d8c653d --- /dev/null +++ b/Android/tools/perf/queries/rails.sql @@ -0,0 +1,14 @@ +INCLUDE PERFETTO MODULE android.power_rails; + +SELECT + power_rail_name AS rail, + SUM( + energy_delta * + (MIN(ts + dur, {{END_NS}}) - MAX(ts, {{START_NS}})) / dur + ) AS energy_uws +FROM android_power_rails_counters +WHERE dur > 0 + AND ts < {{END_NS}} + AND ts + dur > {{START_NS}} +GROUP BY power_rail_name +ORDER BY power_rail_name; diff --git a/Android/tools/perf/queries/trace_bounds.sql b/Android/tools/perf/queries/trace_bounds.sql new file mode 100644 index 000000000..b5b9e4010 --- /dev/null +++ b/Android/tools/perf/queries/trace_bounds.sql @@ -0,0 +1 @@ +SELECT start_ts, end_ts FROM trace_bounds; diff --git a/Android/tools/perf/run_benchmark.py b/Android/tools/perf/run_benchmark.py new file mode 100644 index 000000000..637abe924 --- /dev/null +++ b/Android/tools/perf/run_benchmark.py @@ -0,0 +1,157 @@ +"""Установить benchmark APK и запустить Macrobenchmark без очистки логина.""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +from perf_device import ADB_COMMAND, PerfDeviceError, run_adb, verify_device + + +PERF_DIR = Path(__file__).resolve().parent +ANDROID_ROOT = PERF_DIR.parents[1] +OUTPUT_ROOT = PERF_DIR / "out" +APP_APK = ( + ANDROID_ROOT + / "Barkfluff.Client.Android" + / "app" + / "build" + / "outputs" + / "apk" + / "nightly" + / "benchmark" + / "app-v1-nightly-benchmark.apk" +) +BENCHMARK_APK = ( + ANDROID_ROOT + / "benchmark" + / "build" + / "outputs" + / "apk" + / "benchmark" + / "benchmark-benchmark.apk" +) +INSTRUMENTATION = "com.barkfluff.benchmark/androidx.test.runner.AndroidJUnitRunner" +REMOTE_OUTPUT = "/sdcard/Android/media/com.barkfluff.benchmark/" + + +def parse_instrumentation_output(output: str) -> str | None: + if "INSTRUMENTATION_FAILED" in output: + return "INSTRUMENTATION_FAILED" + + crash = re.search(r"(?m)^INSTRUMENTATION_RESULT: shortMsg=(.+)$", output) + if crash: + return f"process crash: {crash.group(1).strip()}" + + if "FAILURES!!!" in output: + counts = re.search( + r"(?m)^Tests run:\s*(\d+)\s*,\s*Failures:\s*(\d+)\s*$", + output, + ) + failed_tests = re.findall(r"(?m)^\s*\d+\)\s+(.+)$", output) + details = [] + if counts: + details.append(f"{counts.group(1)} tests, {counts.group(2)} failures") + if failed_tests: + names = ", ".join(failed_tests[:3]) + if len(failed_tests) > 3: + names += ", …" + details.append(f"failing tests: {names}") + return "; ".join(details) if details else "FAILURES!!!" + + runner_summaries = [ + line.strip() + for line in output.splitlines() + if line.strip().startswith("OK (") or line.strip() == "FAILURES!!!" + ] + if ( + not runner_summaries + or not re.fullmatch(r"OK \(\d+ tests?\)", runner_summaries[-1]) + ): + return "missing final OK (...) summary" + return None + + +def run_benchmark( + serial: str, + expect_model: str, + *, + test_class: str | None = None, + install_only: bool = False, + adb_command: tuple[str, ...] = ADB_COMMAND, +) -> int: + missing = [path for path in (APP_APK, BENCHMARK_APK) if not path.is_file()] + if missing: + raise FileNotFoundError("Сначала соберите benchmark APK: " + ", ".join(map(str, missing))) + + verify_device(serial, expect_model, adb_command=adb_command) + run_adb(serial, "install", "-r", str(APP_APK), adb_command=adb_command) + run_adb(serial, "install", "-r", str(BENCHMARK_APK), adb_command=adb_command) + if install_only: + print("Оба benchmark APK установлены. Логин и выдачу POST_NOTIFICATIONS выполните вручную.") + return 0 + + command = ["shell", "am", "instrument", "-w"] + if test_class: + command.extend(("-e", "class", test_class)) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + output_dir = OUTPUT_ROOT / timestamp + output_dir.mkdir(parents=True, exist_ok=False) + command.append(INSTRUMENTATION) + instrumentation = run_adb( + serial, + *command, + adb_command=adb_command, + check=False, + ) + instrumentation_output = instrumentation.stdout + instrumentation.stderr + (output_dir / "instrumentation.txt").write_text( + instrumentation_output, + encoding="utf-8", + ) + run_adb(serial, "pull", REMOTE_OUTPUT, str(output_dir), adb_command=adb_command) + print(f"Результаты Macrobenchmark сохранены: {output_dir}") + failure_summary = parse_instrumentation_output(instrumentation_output) + if failure_summary is not None: + print(f"Проблема Macrobenchmark: {failure_summary}", file=sys.stderr) + if failure_summary is not None or instrumentation.returncode != 0: + reason = failure_summary or f"код adb {instrumentation.returncode}" + raise PerfDeviceError( + f"Инструментирование завершилось с ошибкой ({reason}); " + f"результаты сохранены в {output_dir}." + ) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", required=True, help="Явный adb serial устройства") + parser.add_argument("--expect-model", required=True, help='Ожидаемая модель, например "Pixel 10 Pro"') + parser.add_argument("--class", dest="test_class", help="Полное имя benchmark-класса") + parser.add_argument( + "--install-only", + action="store_true", + help="Установить APK, сохранив текущие данные и не запуская тесты", + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + try: + return run_benchmark( + args.serial, + args.expect_model, + test_class=args.test_class, + install_only=args.install_only, + ) + except (PerfDeviceError, OSError) as error: + print(f"Ошибка Macrobenchmark: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Android/tools/perf/samples/data_loss.csv b/Android/tools/perf/samples/data_loss.csv new file mode 100644 index 000000000..5da8c03f7 --- /dev/null +++ b/Android/tools/perf/samples/data_loss.csv @@ -0,0 +1,2 @@ +"name","idx","severity","source","value" +long_trace_mode_bytes_overwritten,0,data_loss,trace,4096 diff --git a/Android/tools/perf/samples/data_loss_clean.csv b/Android/tools/perf/samples/data_loss_clean.csv new file mode 100644 index 000000000..70b4fe8b1 --- /dev/null +++ b/Android/tools/perf/samples/data_loss_clean.csv @@ -0,0 +1 @@ +"name","idx","severity","source","value" diff --git a/Android/tools/perf/samples/fake_adb.py b/Android/tools/perf/samples/fake_adb.py new file mode 100644 index 000000000..b7ccf76ce --- /dev/null +++ b/Android/tools/perf/samples/fake_adb.py @@ -0,0 +1,28 @@ +"""Подставной adb для тестов проверки serial и модели без устройства.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + + +args = sys.argv[1:] +log_path = os.environ.get("PERF_TEST_ADB_LOG") +if log_path: + with Path(log_path).open("a", encoding="utf-8") as log_file: + log_file.write(json.dumps(args) + "\n") + +if len(args) < 3 or args[0] != "-s": + print("missing -s serial", file=sys.stderr) + raise SystemExit(2) + +device_command = args[2:] +if device_command == ["get-state"]: + print("device") +elif device_command == ["shell", "getprop", "ro.product.model"]: + print(os.environ.get("PERF_TEST_ADB_MODEL", "Pixel 10 Pro")) +else: + print("unexpected device action", file=sys.stderr) + raise SystemExit(3) diff --git a/Android/tools/perf/samples/pings.csv b/Android/tools/perf/samples/pings.csv new file mode 100644 index 000000000..62a8e50db --- /dev/null +++ b/Android/tools/perf/samples/pings.csv @@ -0,0 +1,2 @@ +"completed_count","mean_duration_ms","unfinished_count" +199,42.5,0 diff --git a/Android/tools/perf/samples/rails.csv b/Android/tools/perf/samples/rails.csv new file mode 100644 index 000000000..d201ccaa0 --- /dev/null +++ b/Android/tools/perf/samples/rails.csv @@ -0,0 +1,3 @@ +"rail","energy_uws" +modem,1200000 +cpu_big,3400000 diff --git a/Android/tools/perf/samples/trace_bounds.csv b/Android/tools/perf/samples/trace_bounds.csv new file mode 100644 index 000000000..7f1ba3df4 --- /dev/null +++ b/Android/tools/perf/samples/trace_bounds.csv @@ -0,0 +1,2 @@ +"start_ts","end_ts" +1000000000,700000000000 diff --git a/Android/tools/perf/samples/trace_processor_error.txt b/Android/tools/perf/samples/trace_processor_error.txt new file mode 100644 index 000000000..1059e5a35 --- /dev/null +++ b/Android/tools/perf/samples/trace_processor_error.txt @@ -0,0 +1 @@ +Error: no such column: missing_column diff --git a/Android/tools/perf/test_perf_tools.py b/Android/tools/perf/test_perf_tools.py new file mode 100644 index 000000000..10ac19f1d --- /dev/null +++ b/Android/tools/perf/test_perf_tools.py @@ -0,0 +1,213 @@ +"""Проверки разбора trace processor и отказа перед командами замера.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from capture_power import ( + TraceProcessorError, + analyze_trace, + parse_data_loss, + parse_pings, + parse_rails, + parse_trace_bounds, + require_trace_processor_success, + run_trace_query, +) +from perf_device import PerfDeviceError, verify_device +from run_benchmark import parse_instrumentation_output + + +SAMPLES = Path(__file__).resolve().parent / "samples" +FAKE_ADB = SAMPLES / "fake_adb.py" + + +def read_sample(name: str) -> str: + return (SAMPLES / name).read_text(encoding="utf-8") + + +class PerfToolTests(unittest.TestCase): + def test_instrumentation_ok_output_is_accepted(self) -> None: + output = """INSTRUMENTATION_STATUS: stream= +Time: 12.34 + +OK (6 tests) +INSTRUMENTATION_CODE: -1 +""" + self.assertIsNone(parse_instrumentation_output(output)) + + def test_instrumentation_failures_are_summarized(self) -> None: + output = """There were 2 failures: +1) openFirstChat(com.barkfluff.benchmark.ChatOpenBenchmark) +java.lang.AssertionError +2) scrollFirstChat(com.barkfluff.benchmark.ChatScrollBenchmark) +java.lang.AssertionError +FAILURES!!! +Tests run: 6, Failures: 2 +INSTRUMENTATION_CODE: -1 +""" + summary = parse_instrumentation_output(output) + self.assertIsNotNone(summary) + self.assertIn("6 tests, 2 failures", summary) + self.assertIn("openFirstChat", summary) + self.assertIn("scrollFirstChat", summary) + + def test_instrumentation_process_crash_is_rejected(self) -> None: + output = """INSTRUMENTATION_RESULT: shortMsg=Process crashed. +INSTRUMENTATION_CODE: 0 +""" + self.assertEqual( + parse_instrumentation_output(output), + "process crash: Process crashed.", + ) + + def test_instrumentation_failed_marker_is_rejected(self) -> None: + output = "INSTRUMENTATION_FAILED: com.example.TestRunner\n" + self.assertEqual( + parse_instrumentation_output(output), + "INSTRUMENTATION_FAILED", + ) + + def test_instrumentation_without_final_ok_is_rejected(self) -> None: + output = "INSTRUMENTATION_STATUS: stream=started\nINSTRUMENTATION_CODE: 0\n" + self.assertEqual( + parse_instrumentation_output(output), + "missing final OK (...) summary", + ) + + def test_clean_trace_samples_parse(self) -> None: + self.assertEqual(parse_data_loss(read_sample("data_loss_clean.csv")), []) + self.assertEqual(parse_trace_bounds(read_sample("trace_bounds.csv")), (1_000_000_000, 700_000_000_000)) + self.assertEqual( + parse_rails(read_sample("rails.csv")), + [ + {"rail": "modem", "energy_uws": 1_200_000.0}, + {"rail": "cpu_big", "energy_uws": 3_400_000.0}, + ], + ) + self.assertEqual( + parse_pings(read_sample("pings.csv")), + {"completed_count": 199, "mean_duration_ms": 42.5, "unfinished_count": 0}, + ) + + def test_data_loss_sample_is_reported(self) -> None: + rows = parse_data_loss(read_sample("data_loss.csv")) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["severity"], "data_loss") + self.assertEqual(rows[0]["value"], "4096") + + def test_trace_processor_error_sample_is_fatal(self) -> None: + error_output = read_sample("trace_processor_error.txt") + with self.assertRaisesRegex(TraceProcessorError, "no such column"): + require_trace_processor_success(1, "", error_output) + + def test_malformed_trace_processor_csv_is_fatal(self) -> None: + with self.assertRaises(TraceProcessorError): + parse_trace_bounds("start_ts,end_ts\nnot-a-number,20\n") + with self.assertRaises(TraceProcessorError): + parse_pings("completed_count,mean_duration_ms,unfinished_count\n1\n") + + def test_trace_processor_uses_v58_query_argv_and_parses_stdout_only(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + directory = Path(temporary_dir) + trace_path = directory / "trace.perfetto-trace" + trace_path.touch() + query_path = directory / "pings.sql" + query_path.write_text("SELECT 1;\n", encoding="utf-8") + executable = Path("trace_processor_shell.exe") + stdout = read_sample("pings.csv") + + with patch("capture_power.subprocess.run") as trace_processor_run: + trace_processor_run.return_value.returncode = 0 + trace_processor_run.return_value.stdout = stdout + trace_processor_run.return_value.stderr = "Loading trace: ...\nTrace loaded.\n" + + output = run_trace_query(executable, trace_path, query_path) + + trace_processor_run.assert_called_once_with( + [ + str(executable), + "query", + "-f", + str(directory / ".pings.sql"), + str(trace_path), + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + self.assertEqual(output, stdout) + self.assertEqual( + parse_pings(output), + {"completed_count": 199, "mean_duration_ms": 42.5, "unfinished_count": 0}, + ) + + def test_analysis_window_is_exactly_minutes_times_sixty(self) -> None: + outputs = { + "data_loss.sql": read_sample("data_loss_clean.csv"), + "trace_bounds.sql": read_sample("trace_bounds.csv"), + "rails.sql": read_sample("rails.csv"), + "pings.sql": read_sample("pings.csv"), + } + with patch( + "capture_power.run_trace_query", + side_effect=lambda *args, **_kwargs: outputs[args[2].name], + ): + analysis = analyze_trace( + Path("trace_processor_shell.exe"), + Path("power.perfetto-trace"), + warmup_sec=60, + minutes=10, + ) + + self.assertEqual(analysis["window"]["duration_seconds"], 600) + self.assertEqual( + analysis["window"]["end_ns"] - analysis["window"]["start_ns"], + 600_000_000_000, + ) + self.assertEqual(analysis["status"], "PASS") + + def test_missing_serial_fails_without_running_adb(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + log_path = Path(temporary_dir) / "adb.jsonl" + with patch.dict(os.environ, {"PERF_TEST_ADB_LOG": str(log_path)}): + with self.assertRaisesRegex(PerfDeviceError, "--serial"): + verify_device("", "Pixel 10 Pro", adb_command=(sys.executable, str(FAKE_ADB))) + self.assertFalse(log_path.exists()) + + def test_wrong_model_refuses_before_device_actions(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + log_path = Path(temporary_dir) / "adb.jsonl" + environment = { + "PERF_TEST_ADB_LOG": str(log_path), + "PERF_TEST_ADB_MODEL": "Pixel 8 Pro", + } + with patch.dict(os.environ, environment): + with self.assertRaisesRegex(PerfDeviceError, "Команды замера не запускались"): + verify_device( + "test-serial", + "Pixel 10 Pro", + adb_command=(sys.executable, str(FAKE_ADB)), + ) + + calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()] + self.assertEqual( + calls, + [ + ["-s", "test-serial", "get-state"], + ["-s", "test-serial", "shell", "getprop", "ro.product.model"], + ], + ) + self.assertTrue(all(call[0:2] == ["-s", "test-serial"] for call in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Obsidian/ClaudeVault/Clients/Android.md b/Obsidian/ClaudeVault/Clients/Android.md index 6ad6f466f..c7fca8bd7 100644 --- a/Obsidian/ClaudeVault/Clients/Android.md +++ b/Obsidian/ClaudeVault/Clients/Android.md @@ -17,9 +17,12 @@ Kotlin-клиент в Android/Barkfluff.Client.Android. Поддерживае | min / compile / target SDK | 31 / 36 / 36 | Android/Barkfluff.Client.Android/app/build.gradle.kts | | Java bytecode | 17; Gradle daemon toolchain — JDK 21; CI — JDK 21 | Android/Barkfluff.Client.Android/app/build.gradle.kts, Android/gradle/gradle-daemon-jvm.properties, .github/workflows/build-client-android.yml, .github/workflows/test-clients.yml | | RPC transport | gRPC-OkHttp 1.60.0; gRPC Kotlin 1.4.1 | Android/core/build.gradle.kts | +| Macrobenchmark | androidx.benchmark:benchmark-macro-junit4 1.5.0; androidx.test.uiautomator:uiautomator 2.4.0 | Android/gradle/libs.versions.toml | Сборка debug-варианта из Android: ./gradlew :app-v1:assembleStableDebug; короткие имена задач неоднозначны из-за flavors dev/nightly/stable (compileDebugKotlin → compileStableDebugKotlin и т.п.). На Windows обёртку вызывают через gradlew.bat: скрипт gradlew имеет CRLF и завершается до старта Gradle. Для flavors stable, dev и nightly заданы отдельные applicationId; стабильный канал публикуется как release в ClientStorage. Release использует R8, resource shrinking и только arm64-v8a. +Для измерений есть build type `benchmark` у `:app-v1` и instrumentation-модуль `:benchmark` (`com.android.test`), нацеленный на `:app-v1` с flavor `nightly`. Целевое приложение не debuggable, но его merged manifest разрешает shell profiling (`profileable android:shell="true"`); команды сборки и порядок запуска описаны в [[Testing#Android performance]]. Маркеры `BF:appToChats`, `BF:Application.onCreate`, `BF:chatOpen` и `BF:onlinePing` используют платформенный `android.os.Trace` и намеренно остаются в release для Macrobenchmark/Perfetto. StrictMode включается только при `BuildConfig.DEBUG` и пишет disk/network и VM нарушения через `penaltyLog`, не завершая приложение. + ## Границы и основные потоки - :app-v1 — Activities и Fragments на ViewBinding/XML; пользовательский и чат-поиск — Compose. Android/core — gRPC, proto, gateways, репозитории, cache utilities и доменные модели. diff --git a/Obsidian/ClaudeVault/Testing.md b/Obsidian/ClaudeVault/Testing.md index 145318c17..1f01fee94 100644 --- a/Obsidian/ClaudeVault/Testing.md +++ b/Obsidian/ClaudeVault/Testing.md @@ -16,6 +16,47 @@ Android instrumented-тесты локально компилируют как `PendingChatReadRangeDaoTest` checks cutoff coalescing and stale completion; migration instrumentation covers Room v5→v6. `MessageNotificationTest` covers scoped actions, extras/history roundtrip, hash/legacy collision safety, bounded sender icons, late event/avatar merge, reply/read persistence failures and cold/warm/foreign-scope navigation. `ColdNotificationScenarioTest` covers reply/read-range recovery after process death and independent read retry. Compile/assemble does not execute these instrumented scenarios. +## Android performance + +Измерительная инфраструктура относится к Android V1 (`:app-v1`) и запускается на физическом Pixel 10 Pro с тестовым аккаунтом и одним длинным чатом. Эмулятор не используется. Сборка не запускает задачи установки или подключения к устройству; оба perf-скрипта требуют `--serial` и `--expect-model`, проверяют состояние и модель перед командами замера и всегда добавляют выбранный serial к `adb`. + +Локальная проверка на Windows из корня репозитория: + +```powershell +py -3 -m unittest discover -s Android/tools/perf -p "test_*.py" +py -3 Android/tools/perf/capture_power.py --help +py -3 Android/tools/perf/run_benchmark.py --help +py -3 Android/tools/check_android_ui.py +``` + +Первый прогон выполняй в `Android/`: + +1. Собери app и instrumentation APK. +2. Установи их в режиме `--install-only`; если появится `INSTALL_FAILED_UPDATE_INCOMPATIBLE` из-за CI-подписи уже установленного приложения, один раз удали ту установку вручную и повтори шаг. +3. Выдай `POST_NOTIFICATIONS`, войди в тестовый аккаунт и открой длинный чат вручную. +4. Запусти полный Macrobenchmark два раза подряд. После каждого запуска должен открываться авторизованный список без повторного входа. + +```powershell +.\gradlew.bat :app-v1:assembleNightlyBenchmark :benchmark:assembleBenchmark +py -3 tools/perf/run_benchmark.py --serial --expect-model "Pixel 10 Pro" --install-only +adb -s shell pm grant com.barkfluff.nightly android.permission.POST_NOTIFICATIONS +py -3 tools/perf/run_benchmark.py --serial --expect-model "Pixel 10 Pro" +``` + +Не очищай данные приложения: прогоны используют сохранённый логин. + +Полный запуск из `Android/`: `py -3 tools/perf/run_benchmark.py --serial --expect-model "Pixel 10 Pro"`; `--class` запускает один benchmark-класс. Макробенчмарки покрывают холодный старт, открытие чата и прокрутку в режимах `CompilationMode.None` и `Partial` с тремя итерациями прогрева. + +Перед каждой итерацией открытие и прокрутка продолжают работу с видимого списка чатов, возвращаются из открытого чата или заново запускают приложение при неизвестном состоянии; финальное ожидание `chatTitle` сохраняет понятную ошибку для незалогиненного аккаунта. + +`run_benchmark.py` сохраняет результаты и завершает работу с ошибкой при `FAILURES!!!`, сбое instrumentation или отсутствии финальной строки `OK (...)`, даже когда `am instrument` вернул код 0. + +`BF:appToChats` заканчивается на первом реальном чате, `BF:chatOpen` — на первом непустом списке сообщений, а `BF:onlinePing` остаётся async-секцией. Трейсы отдельных итераций Macrobenchmark находятся в его `build/outputs`; скачанные отчёты и логи — в `Android/tools/perf/out//`. + +Энергозамер запускается из `Android/` командой `py -3 tools/perf/capture_power.py --serial --expect-model "Pixel 10 Pro" --trace-processor "C:\Tools\Perfetto\trace_processor_shell.exe"`. По умолчанию записываются 60 секунд прогрева, 10 минут замера и 30 секунд запаса; SQL-анализ использует ровно `minutes * 60` секунд начиная с `trace_bounds.start_ts + warmup`. Без trace processor анализ получает статус `NOT_RUN`; трейс всё равно сохраняется. `summary.json`, сырой Perfetto trace и справочный `batterystats.txt` остаются в папке прогона. При ошибке анализа, ошибках trace processor или потерях данных команда завершается с ненулевым кодом. + +Для энергетических выводов используй только аппаратные power rails (ODPM), сравнивая подсистемы, в первую очередь модем/радио. Батарейные счётчики Perfetto и `batterystats` при USB-подключении — справочные данные. Во время сценария выключи Wi-Fi, оставь мобильную сеть, включённый экран и приложение на списке чатов; те же действия можно измерить Android Studio Power Profiler. Число завершённых `BF:onlinePing` — диагностическая метрика: ожидаемый порядок `600 / (3 + средняя длительность RPC в секундах)`, обычно меньше 200; незавершённых ping-секций быть не должно. Debug-сборка StrictMode только пишет нарушения в лог: `adb -s logcat -s StrictMode:D`. + ## Android PQXDH `Android/core/src/androidTest/java/com/barkfluff/client/repository/PqxdhHandshakeInstrumentedTest.kt` использует настоящий libsignal JNI и production `PrekeyBundle.toLibsignal()` mapping: handshake/reply после повторного открытия store, optional EC one-time key, Kyber one-time и last-resort fallback для разных sender devices, отказ malformed/signature-invalid bundle, replay tuple и rollback failed decrypt. `Android/core/src/androidTest/java/com/barkfluff/client/crypto/PrekeyManagerInstrumentedTest.kt` покрывает stable registration/replenishment retry, подтверждение marker через own status, старый сервер, serialized bootstrap, rotation/retention, legacy identity/session migration, lost-ack rotation с reopen и отклонением несовпадающего ключа, no-change status RPC count и logout во время upload. `BarkFluffSignalStoreInstrumentedTest.kt` проверяет rollback с reopen после прерванной операции и сохранение старого snapshot плюс fail-closed поведение при отклонённом durable commit. Repository instrumentation дополнительно покрывает initial history handoff, прерывание после cache upsert до replay marker, delayed invite, pending accept RPC retry, expiry/reinvite с тем же chat ID, отсутствие resurrection после clear и logout во время bundle fetch/handoff, scrub pending initial при reject/reinvite с сохранением старого invite ID, ack вне history lock и восстановление после сбоя crypto scrub при clear. Store test проверяет failed cleanup/retry без credentials, изоляцию другого scope, очистку unmigrated legacy-файлов при пустом и null scope и независимый retry каждой записи журнала. JVM-тесты app (`CryptoExitSequenceTest`, `SecretSendOutcomeTest`) проверяют gating REJECTED/UNAVAILABLE, завершение logout после отмены и разделение успешной отправки и ошибки истории; `ChatCacheRepositoryClearTest` — видимость SQLCipher записи после возврата suspend API и idempotent upsert. Эти instrumentation tests требуют Android runtime; compile или assemble не доказывает, что JNI сценарии прошли.