From 315820b14692b0de7624f74c9396ac760558b0fb Mon Sep 17 00:00:00 2001 From: Wai Hon Law Date: Wed, 2 Jul 2025 13:52:19 -0700 Subject: [PATCH] [gallery] add Analytics events: model_download PiperOrigin-RevId: 778630845 --- .../com/google/ai/edge/gallery/Analytics.kt | 36 +++++++++++++ .../google/ai/edge/gallery/MainActivity.kt | 29 +++++----- .../edge/gallery/data/DownloadRepository.kt | 53 +++++++++++++++++++ .../edge/gallery/ui/common/ClickableLink.kt | 8 ++- .../ai/edge/gallery/ui/home/HomeScreen.kt | 12 ++++- .../edge/gallery/ui/llmchat/LlmChatScreen.kt | 7 +++ .../ui/llmsingleturn/LlmSingleTurnScreen.kt | 10 ++++ .../gallery/ui/navigation/GalleryNavGraph.kt | 7 +++ 8 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/Analytics.kt diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/Analytics.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/Analytics.kt new file mode 100644 index 0000000..9d496b5 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/Analytics.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery + +import android.util.Log +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase + +private var hasLoggedAnalyticsWarning = false + +val firebaseAnalytics: FirebaseAnalytics? + get() = + runCatching { Firebase.analytics } + .onFailure { exception -> + // Firebase.analytics can throw an exception if goolgle-services is not set up, e.g., + // missing google-services.json. + if (!hasLoggedAnalyticsWarning) { + Log.w("AGAnalyticsFirebase", "Firebase Analytics is not available", exception) + } + } + .getOrNull() diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt index b923c4f..6fe29d5 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/MainActivity.kt @@ -18,7 +18,6 @@ package com.google.ai.edge.gallery import android.os.Build import android.os.Bundle -import android.util.Log import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -26,31 +25,20 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.core.os.bundleOf import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.google.ai.edge.gallery.ui.theme.GalleryTheme import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.ktx.Firebase import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { - private var firebaseAnalytics: FirebaseAnalytics? = null - override fun onCreate(savedInstanceState: Bundle?) { - firebaseAnalytics = - runCatching { Firebase.analytics } - .onFailure { exception -> - // Firebase.analytics can throw an exception if goolgle-services is not set up, e.g., - // missing google-services.json. - Log.w(TAG, "Firebase Analytics is not available", exception) - } - .getOrNull() + super.onCreate(savedInstanceState) installSplashScreen() - super.onCreate(savedInstanceState) enableEdgeToEdge() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Fix for three-button nav not properly going edge-to-edge. @@ -62,6 +50,19 @@ class MainActivity : ComponentActivity() { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + override fun onResume() { + super.onResume() + + firebaseAnalytics?.logEvent( + FirebaseAnalytics.Event.APP_OPEN, + bundleOf( + "app_version" to BuildConfig.VERSION_NAME, + "os_version" to Build.VERSION.SDK_INT.toString(), + "device_model" to Build.MODEL, + ), + ) + } + companion object { private const val TAG = "AGMainActivity" } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt index 2294fca..8ef13c1 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt @@ -28,6 +28,7 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri +import androidx.core.os.bundleOf import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder @@ -39,6 +40,7 @@ import androidx.work.WorkQuery import com.google.ai.edge.gallery.AppLifecycleProvider import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.common.readLaunchInfo +import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.worker.DownloadWorker import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures @@ -82,6 +84,15 @@ class DefaultDownloadRepository( private val lifecycleProvider: AppLifecycleProvider, ) : DownloadRepository { private val workManager = WorkManager.getInstance(context) + /** + * Stores the start time of a model download. + * + * We use SharedPreferences to persist the download start times. This ensures that the data is + * still available after the app restarts. The key is the model name and the value is the download + * start time in milliseconds. + */ + private val downloadStartTimeSharedPreferences = + context.getSharedPreferences("download_start_time_ms", Context.MODE_PRIVATE) override fun downloadModel( model: Model, @@ -175,6 +186,17 @@ class DefaultDownloadRepository( workManager.getWorkInfoByIdLiveData(workerId).observeForever { workInfo -> if (workInfo != null) { when (workInfo.state) { + WorkInfo.State.ENQUEUED -> { + with(downloadStartTimeSharedPreferences.edit()) { + putLong(model.name, System.currentTimeMillis()) + apply() + } + firebaseAnalytics?.logEvent( + "model_download", + bundleOf("event_type" to "start", "model_id" to model.name), + ) + } + WorkInfo.State.RUNNING -> { val receivedBytes = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, 0L) val downloadRate = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RATE, 0L) @@ -210,6 +232,21 @@ class DefaultDownloadRepository( text = context.getString(R.string.notification_content_success).format(model.name), modelName = model.name, ) + + val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L) + val duration = System.currentTimeMillis() - startTime + firebaseAnalytics?.logEvent( + "model_download", + bundleOf( + "event_type" to "success", + "model_id" to model.name, + "duration_ms" to duration, + ), + ) + with(downloadStartTimeSharedPreferences.edit()) { + remove(model.name) + apply() + } } WorkInfo.State.FAILED, @@ -233,6 +270,22 @@ class DefaultDownloadRepository( model, ModelDownloadStatus(status = status, errorMessage = errorMessage), ) + + val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L) + val duration = System.currentTimeMillis() - startTime + // TODO: Add failure reasons + firebaseAnalytics?.logEvent( + "model_download", + bundleOf( + "event_type" to "failure", + "model_id" to model.name, + "duration_ms" to duration, + ), + ) + with(downloadStartTimeSharedPreferences.edit()) { + remove(model.name) + apply() + } } else -> {} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt index 7d4abdb..cb8e2a3 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/ClickableLink.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.theme.customColors @Composable @@ -62,7 +64,11 @@ fun ClickableLink(url: String, linkText: String, icon: ImageVector) { text = annotatedText, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 6.dp).clickable { uriHandler.openUri(url) }, + modifier = + Modifier.padding(start = 6.dp).clickable { + uriHandler.openUri(url) + firebaseAnalytics?.logEvent("resource_link_click", bundleOf("link_destination" to url)) + }, ) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt index 58427ab..76cfc09 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.LinkAnnotation @@ -100,11 +101,13 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.os.bundleOf import com.google.ai.edge.gallery.GalleryTopAppBar import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.AppBarAction import com.google.ai.edge.gallery.data.AppBarActionType import com.google.ai.edge.gallery.data.Task +import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.proto.ImportedModel import com.google.ai.edge.gallery.ui.common.TaskIcon import com.google.ai.edge.gallery.ui.common.getTaskBgColor @@ -334,17 +337,24 @@ private fun TaskList( val screenHeightDp = remember { with(density) { windowInfo.containerSize.height.toDp() } } val sizeFraction = remember { ((screenWidthDp - 360.dp) / (410.dp - 360.dp)).coerceIn(0f, 1f) } val linkColor = MaterialTheme.customColors.linkColor + val url = "https://huggingface.co/litert-community" + val uriHandler = LocalUriHandler.current val introText = buildAnnotatedString { append("Welcome to Google AI Edge Gallery! Explore a world of amazing on-device models from ") + // TODO: Consolidate the link clicking logic into ui/common/ClickableLink.kt. withLink( link = LinkAnnotation.Url( - url = "https://huggingface.co/litert-community", // Replace with the actual URL + url = url, styles = TextLinkStyles( style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline) ), + linkInteractionListener = { _ -> + firebaseAnalytics?.logEvent("resource_link_click", bundleOf("link_destination" to url)) + uriHandler.openUri(url) + }, ) ) { append("LiteRT community") diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt index e61e2eb..9340573 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatScreen.kt @@ -20,6 +20,8 @@ import android.graphics.Bitmap import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.core.os.bundleOf +import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.chat.ChatMessageAudioClip import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText @@ -132,6 +134,11 @@ fun ChatViewWrapper( ) }, ) + + firebaseAnalytics?.logEvent( + "generate_action", + bundleOf("capability_name" to viewModel.task.type.toString(), "model_id" to model.name), + ) } }, onRunAgainClicked = { model, message -> diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt index f62e2f8..5c04988 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnScreen.kt @@ -43,8 +43,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.core.os.bundleOf import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB +import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.common.ErrorDialog import com.google.ai.edge.gallery.ui.common.ModelPageAppBar import com.google.ai.edge.gallery.ui.common.chat.ModelDownloadStatusInfoPanel @@ -167,6 +169,14 @@ fun LlmSingleTurnScreen( modelManagerViewModel = modelManagerViewModel, onSend = { fullPrompt -> viewModel.generateResponse(model = selectedModel, input = fullPrompt) + + firebaseAnalytics?.logEvent( + "generate_action", + bundleOf( + "capability_name" to task.type.toString(), + "model_id" to selectedModel.name, + ), + ) }, onStopButtonClicked = { model -> viewModel.stopResponse(model = model) }, modifier = Modifier.fillMaxSize(), diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt index e4ebd48..a9b1bc4 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -54,6 +55,7 @@ import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.TaskType import com.google.ai.edge.gallery.data.getModelByName +import com.google.ai.edge.gallery.firebaseAnalytics import com.google.ai.edge.gallery.ui.home.HomeScreen import com.google.ai.edge.gallery.ui.llmchat.LlmAskAudioDestination import com.google.ai.edge.gallery.ui.llmchat.LlmAskAudioScreen @@ -144,6 +146,11 @@ fun GalleryNavHost( navigateToTaskScreen = { task -> pickedTask = task showModelManager = true + + firebaseAnalytics?.logEvent( + "capability_select", + bundleOf("capability_name" to task.type.toString()), + ) }, )