diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt index 97e0fb2787..17e18df7f4 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt @@ -39,9 +39,8 @@ import kotlin.test.assertTrue /** * Migration coverage for discovery tables (D011). * - * Verifies that the discovery schema (version 38→39 auto-migration) creates - * the expected tables, supports CRUD operations, enforces foreign key cascade - * behavior, and respects column defaults. + * Verifies that the discovery schema (version 38→39 auto-migration) creates the expected tables, supports CRUD + * operations, enforces foreign key cascade behavior, and respects column defaults. */ @RunWith(AndroidJUnit4::class) @Config(sdk = [34]) @@ -71,12 +70,13 @@ class DiscoveryMigrationTest { @Test fun discoverySessionTable_insertAndRetrieve() = runTest { - val session = DiscoverySessionEntity( - timestamp = 1_000_000L, - presetsScanned = "LONG_FAST,SHORT_FAST", - homePreset = "LONG_FAST", - completionStatus = "complete", - ) + val session = + DiscoverySessionEntity( + timestamp = 1_000_000L, + presetsScanned = "LONG_FAST,SHORT_FAST", + homePreset = "LONG_FAST", + completionStatus = "complete", + ) val id = discoveryDao.insertSession(session) assertTrue(id > 0, "Insert should return positive auto-generated ID") val loaded = discoveryDao.getSession(id) @@ -88,14 +88,15 @@ class DiscoveryMigrationTest { @Test fun discoveryPresetResultTable_insertAndRetrieve() = runTest { val sessionId = discoveryDao.insertSession(testSession()) - val result = DiscoveryPresetResultEntity( - sessionId = sessionId, - presetName = "LONG_FAST", - dwellDurationSeconds = 30, - uniqueNodes = 5, - directNeighborCount = 3, - meshNeighborCount = 2, - ) + val result = + DiscoveryPresetResultEntity( + sessionId = sessionId, + presetName = "LONG_FAST", + dwellDurationSeconds = 30, + uniqueNodes = 5, + directNeighborCount = 3, + meshNeighborCount = 2, + ) val resultId = discoveryDao.insertPresetResult(result) assertTrue(resultId > 0) val results = discoveryDao.getPresetResults(sessionId) @@ -108,17 +109,18 @@ class DiscoveryMigrationTest { fun discoveredNodeTable_insertAndRetrieve() = runTest { val sessionId = discoveryDao.insertSession(testSession()) val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId)) - val node = DiscoveredNodeEntity( - presetResultId = presetId, - nodeNum = 12345, - shortName = "TST", - longName = "Test Node", - neighborType = "direct", - latitude = 37.7749, - longitude = -122.4194, - snr = 8.5f, - rssi = -65, - ) + val node = + DiscoveredNodeEntity( + presetResultId = presetId, + nodeNum = 12345, + shortName = "TST", + longName = "Test Node", + neighborType = "direct", + latitude = 37.7749, + longitude = -122.4194, + snr = 8.5f, + rssi = -65, + ) val nodeId = discoveryDao.insertDiscoveredNode(node) assertTrue(nodeId > 0) val nodes = discoveryDao.getDiscoveredNodes(presetId) @@ -134,11 +136,7 @@ class DiscoveryMigrationTest { @Test fun sessionEntity_defaultValues() = runTest { // Insert with only required fields — verify defaults - val session = DiscoverySessionEntity( - timestamp = 1L, - presetsScanned = "A", - homePreset = "A", - ) + val session = DiscoverySessionEntity(timestamp = 1L, presetsScanned = "A", homePreset = "A") val id = discoveryDao.insertSession(session) val loaded = discoveryDao.getSession(id)!! assertEquals(0, loaded.totalUniqueNodes) @@ -263,4 +261,3 @@ class DiscoveryMigrationTest { // endregion } - diff --git a/feature/discovery/build.gradle.kts b/feature/discovery/build.gradle.kts index 02351a3f1b..bfe4b06b77 100644 --- a/feature/discovery/build.gradle.kts +++ b/feature/discovery/build.gradle.kts @@ -51,5 +51,7 @@ kotlin { } commonTest.dependencies { implementation(projects.core.testing) } + + androidMain.dependencies { implementation(libs.mlkit.genai.prompt) } } } diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt index 7588313fff..6fc800ef49 100644 --- a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt +++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt @@ -16,37 +16,99 @@ */ package org.meshtastic.feature.discovery.ai +import co.touchlab.kermit.Logger +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.GenerativeModel +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity import org.meshtastic.core.database.entity.DiscoverySessionEntity import org.meshtastic.feature.discovery.DiscoverySummaryGenerator -// TODO: Replace with real Gemini Nano on-device implementation once -// `com.google.ai.edge:aicore` or `com.google.android.gms:play-services-generativeai` -// is added to libs.versions.toml. The implementation should: -// 1. Check model availability via GenerativeModel.isAvailable() -// 2. Build a structured prompt with session metrics (nodes, utilization, presets) -// 3. Call generateContent() with the prompt -// 4. Fall back to the algorithmic generator on any error - /** - * Android provider that will use Gemini Nano for on-device AI summaries. + * Android provider that uses Gemini Nano via ML Kit GenAI Prompt API for on-device AI summaries. * - * Currently delegates to [DiscoverySummaryGenerator] because the Gemini Nano SDK dependency is not yet in the version - * catalog. + * Falls back to [DiscoverySummaryGenerator] when: + * - The on-device model is unavailable (unsupported hardware or not downloaded) + * - Generation fails for any reason */ @Single(binds = [DiscoverySummaryAiProvider::class]) class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider { - // Delegates to DiscoverySummaryGenerator (algorithmic) so results are always available. - // When real Gemini Nano SDK is wired, this should check GenerativeModel.isAvailable() at runtime. - override val isAvailable: Boolean = true + private val log = Logger.withTag("GeminiNanoSummary") + + private val generativeModel: GenerativeModel? by lazy { + @Suppress("TooGenericExceptionCaught") // ML Kit throws undocumented RuntimeExceptions + try { + Generation.getClient() + } catch (e: Exception) { + log.w(e) { "Failed to get GenerativeModel client" } + null + } + } + + override val isAvailable: Boolean + get() = checkAvailability() override suspend fun generateSessionSummary( session: DiscoverySessionEntity, presetResults: List, - ): String = generator.generateSessionSummary(session, presetResults) + ): String { + val model = generativeModel + if (model == null || !isAvailable) { + log.d { "Gemini Nano unavailable, using algorithmic fallback" } + return generator.generateSessionSummary(session, presetResults) + } - override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String = - generator.generatePresetSummary(result) + val prompt = generator.buildSessionPrompt(session, presetResults) + return generateOrFallback(model, prompt) { generator.generateSessionSummary(session, presetResults) } + } + + override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String { + val model = generativeModel + if (model == null || !isAvailable) { + return generator.generatePresetSummary(result) + } + + val prompt = generator.buildPresetPrompt(result) + return generateOrFallback(model, prompt) { generator.generatePresetSummary(result) } + } + + private suspend fun generateOrFallback(model: GenerativeModel, prompt: String, fallback: () -> String): String = + try { + val request = + generateContentRequest(TextPart(prompt)) { + temperature = TEMPERATURE + topK = TOP_K + maxOutputTokens = MAX_OUTPUT_TOKENS + } + val response = model.generateContent(request) + val text = response.candidates.firstOrNull()?.text + if (text.isNullOrBlank()) { + log.w { "Gemini Nano returned empty response, using fallback" } + fallback() + } else { + text + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + log.w(e) { "Gemini Nano generation failed, using fallback" } + fallback() + } + + private fun checkAvailability(): Boolean = try { + // FeatureStatus is an IntDef — check synchronously via the lazy model field. + // Note: checkStatus() is suspend in the API; we use a non-suspend heuristic here + // by catching and falling back if unavailable. The actual availability is confirmed + // in generateOrFallback when the suspend call succeeds. + generativeModel != null + } catch (_: Exception) { + false + } + + private companion object { + const val TEMPERATURE = 0.3f + const val TOP_K = 16 + const val MAX_OUTPUT_TOKENS = 200 + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd26c4d72b..a8f69c0399 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ maps-compose = "8.3.0" # ML Kit mlkit-barcode-scanning = "17.3.0" +mlkit-genai-prompt = "1.0.0-beta2" mlkit-translate = "17.0.3" # CameraX @@ -178,6 +179,7 @@ maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" } +mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkit-genai-prompt" } mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }