Skip to content
Open
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
8 changes: 8 additions & 0 deletions .github/workflows/build_android.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,13 @@ jobs:
with:
distribution: 'temurin'
java-version: '21'
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Build
run: ./gradlew assembleRelease
- name: Upload unit test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: Android/src/app/build/reports/tests/testDebugUnitTest/
2 changes: 2 additions & 0 deletions Android/src/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ dependencies {
implementation(libs.moshi.kotlin)
kapt(libs.hilt.android.compiler)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,33 +30,20 @@ import com.google.firebase.messaging.RemoteMessage

class GalleryFcmMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// TODO(developer): Handle FCM messages here.
// Not getting messages here? See why this may be: https://goo.gl/39bRNJ
Log.d(TAG, "From: ${remoteMessage.from}")

// Check if message contains a data payload.
// Log data payload if present.
if (remoteMessage.data.isNotEmpty()) {
Log.d(TAG, "Message data payload: ${remoteMessage.data}")

// Handle message within 10 seconds
handleNow()
}

// Check if message contains a notification payload.
// Display notification for messages that carry a notification payload.
remoteMessage.notification?.let { notification ->
Log.d(TAG, "Message Notification Body: ${notification.body}")
notification.body?.let { body ->
sendNotification(notification.title, body, notification.imageUrl)
}
}

// Also if you intend on generating your own notificatisons as a result of a received FCM
// message, here is where that should be initiated. See sendNotification method below.

}

private fun handleNow() {
Log.d(TAG, "Short lived task is done.")
}

private fun sendNotification(title: String?, messageBody: String, imageUrl: android.net.Uri?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ fun SecretEditorDialog(
if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
leadingIcon = {
val image = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
// TODO: Replace with string resources.
val description = if (passwordVisible) "Hide password" else "Show password"
val description = if (passwordVisible) {
stringResource(R.string.cd_hide_password)
} else {
stringResource(R.string.cd_show_password)
}
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, contentDescription = description)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ class DefaultDownloadRepository(
} else {
sendNotification(
title = context.getString(R.string.notification_title_fail),
text = context.getString(R.string.notification_content_success).format(model.name),
text = context.getString(R.string.notification_content_fail).format(model.name),
taskId = "",
modelName = "",
)
Expand All @@ -253,13 +253,13 @@ class DefaultDownloadRepository(

val startTime = downloadStartTimeSharedPreferences.getLong(model.name, 0L)
val duration = System.currentTimeMillis() - startTime
// TODO: Add failure reasons
firebaseAnalytics?.logEvent(
GalleryEvent.MODEL_DOWNLOAD.id,
bundleOf(
"event_type" to "failure",
"model_id" to model.name,
"duration_ms" to duration,
"failure_reason" to errorMessage.take(100).ifEmpty { "unknown" },
),
)
downloadStartTimeSharedPreferences.edit { remove(model.name) }
Expand Down Expand Up @@ -320,8 +320,7 @@ class DefaultDownloadRepository(

val builder =
NotificationCompat.Builder(context, channelId)
// TODO: replace icon.
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ data class BenchmarkUiState(
val running: Boolean = false,
val totalRunCount: Int = 0,
val completedRunCount: Int = 0,
val errorMessage: String = "",
)

@HiltViewModel
Expand Down Expand Up @@ -114,7 +115,6 @@ constructor(
)
Log.d(TAG, "Running benchmark: ${parts.joinToString("\n")}")

// TODO: handle error.
val startMs = System.currentTimeMillis()
val prefillSpeeds = mutableListOf<Double>()
val decodeSpeeds = mutableListOf<Double>()
Expand All @@ -139,31 +139,42 @@ constructor(
else -> Backend.CPU()
}
val modelPath = model.getPath(context = appContext)
for (i in 0 until runCount) {
Log.d(TAG, "Start running #$i...")
val benchmarkInfo =
benchmark(
modelPath = modelPath,
backend = backend,
prefillTokens = prefillTokens,
decodeTokens = decodeTokens,
cacheDir = cacheDirPath,
)
Log.d(TAG, "Done #$i")

val initTimeMs = benchmarkInfo.initTimeInSecond * 1000.0
if (i == 0) {
firstInitTime = initTimeMs
} else {
nonFirstInitTimes.add(initTimeMs)
}
prefillSpeeds.add(benchmarkInfo.lastPrefillTokensPerSecond)
decodeSpeeds.add(benchmarkInfo.lastDecodeTokensPerSecond)
timesToFirstToken.add(benchmarkInfo.timeToFirstTokenInSecond)
try {
for (i in 0 until runCount) {
Log.d(TAG, "Start running #$i...")
val benchmarkInfo =
benchmark(
modelPath = modelPath,
backend = backend,
prefillTokens = prefillTokens,
decodeTokens = decodeTokens,
cacheDir = cacheDirPath,
)
Log.d(TAG, "Done #$i")

val initTimeMs = benchmarkInfo.initTimeInSecond * 1000.0
if (i == 0) {
firstInitTime = initTimeMs
} else {
nonFirstInitTimes.add(initTimeMs)
}
prefillSpeeds.add(benchmarkInfo.lastPrefillTokensPerSecond)
decodeSpeeds.add(benchmarkInfo.lastDecodeTokensPerSecond)
timesToFirstToken.add(benchmarkInfo.timeToFirstTokenInSecond)

// Mark finish for this run.
setRunProgress(completedRunCount = i + 1)
// Mark finish for this run.
setRunProgress(completedRunCount = i + 1)
}
} catch (e: Exception) {
Log.e(TAG, "Benchmark failed", e)
if (needCleanUpCacheDir) {
benchmarkCacheDir.deleteRecursively()
}
_uiState.update { it.copy(running = false, errorMessage = e.message ?: "Unknown error") }
return@launch
}

val endMs = System.currentTimeMillis()
if (needCleanUpCacheDir) {
benchmarkCacheDir.deleteRecursively()
Expand Down Expand Up @@ -200,6 +211,7 @@ constructor(
val newId = addBenchmarkResult(result = result)
collapseAll()
setExpanded(id = newId, expanded = true)
_uiState.update { it.copy(errorMessage = "") }

setRunning(running = false)
}
Expand Down Expand Up @@ -349,6 +361,10 @@ constructor(
_uiState.update { _uiState.value.copy(results = newResults) }
}

fun clearError() {
_uiState.update { it.copy(errorMessage = "") }
}

fun setAggregation(id: String, aggregation: Aggregation) {
val newResults = _uiState.value.results.toMutableList()
val index = newResults.indexOfFirst { it.id == id }
Expand Down
4 changes: 4 additions & 0 deletions Android/src/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,9 @@
<string name="cd_help">Help</string>
<!-- Content description spoken when the "menu" icon is focused. [CHAR_LIMIT=NONE] -->
<string name="cd_menu">Menu</string>
<!-- Content description spoken when the "show password" toggle is focused. [CHAR_LIMIT=NONE] -->
<string name="cd_show_password">Show password</string>
<!-- Content description spoken when the "hide password" toggle is focused. [CHAR_LIMIT=NONE] -->
<string name="cd_hide_password">Hide password</string>

</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2026 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.customtasks.examplecustomtask

import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test

class ExampleCustomTaskViewModelTest {

private lateinit var viewModel: ExampleCustomTaskViewModel

@Before
fun setUp() {
viewModel = ExampleCustomTaskViewModel()
}

// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------

@Test
fun `initial uiState has textColor set to Black`() {
assertEquals(Color.Black, viewModel.uiState.value.textColor)
}

// ---------------------------------------------------------------------------
// updateTextColor
// ---------------------------------------------------------------------------

@Test
fun `updateTextColor changes textColor in uiState`() {
viewModel.updateTextColor(Color.Red)
assertEquals(Color.Red, viewModel.uiState.value.textColor)
}

@Test
fun `updateTextColor to Blue is reflected in uiState`() {
viewModel.updateTextColor(Color.Blue)
assertEquals(Color.Blue, viewModel.uiState.value.textColor)
}

@Test
fun `updateTextColor can be called multiple times and reflects the latest value`() {
viewModel.updateTextColor(Color.Green)
viewModel.updateTextColor(Color.Yellow)
viewModel.updateTextColor(Color.Cyan)

assertEquals(Color.Cyan, viewModel.uiState.value.textColor)
}

@Test
fun `updateTextColor with same color is idempotent`() {
viewModel.updateTextColor(Color.Red)
viewModel.updateTextColor(Color.Red)

assertEquals(Color.Red, viewModel.uiState.value.textColor)
}

@Test
fun `updateTextColor with White updates state correctly`() {
viewModel.updateTextColor(Color.White)
assertEquals(Color.White, viewModel.uiState.value.textColor)
}

// ---------------------------------------------------------------------------
// State immutability — each update creates a new UiState copy
// ---------------------------------------------------------------------------

@Test
fun `each updateTextColor call produces a new uiState instance`() {
val stateBeforeUpdate = viewModel.uiState.value

viewModel.updateTextColor(Color.Magenta)

val stateAfterUpdate = viewModel.uiState.value
// The new state should be a different object
assert(stateBeforeUpdate !== stateAfterUpdate)
assertEquals(Color.Magenta, stateAfterUpdate.textColor)
}

@Test
fun `uiState before update is not modified by subsequent update`() {
val capturedState = viewModel.uiState.value.copy()

viewModel.updateTextColor(Color.Gray)

// The previously captured copy still has the original color
assertEquals(Color.Black, capturedState.textColor)
assertEquals(Color.Gray, viewModel.uiState.value.textColor)
}
}
Loading