Skip to content

Commit f6bfefd

Browse files
jamesarichCopilot
andcommitted
feat(discovery): wire Gemini Nano via ML Kit GenAI Prompt API
Replace the stub GeminiNanoSummaryProvider with a real implementation that uses com.google.mlkit:genai-prompt:1.0.0-beta2 for on-device AI-powered scan summaries on supported Android hardware. Implementation: - Generation.getClient() to obtain the GenerativeModel - generateContentRequest with TextPart for structured prompts - Temperature 0.3, topK 16, maxOutputTokens 200 for concise output - Graceful fallback to DiscoverySummaryGenerator on any failure - Lazy model initialization with error logging via Kermit The existing buildSessionPrompt() and buildPresetPrompt() methods in DiscoverySummaryGenerator provide the prompt text. On unsupported devices or fdroid builds, the provider falls through to the deterministic algorithmic summary seamlessly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4bf8aaf commit f6bfefd

4 files changed

Lines changed: 114 additions & 51 deletions

File tree

core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,8 @@ import kotlin.test.assertTrue
3939
/**
4040
* Migration coverage for discovery tables (D011).
4141
*
42-
* Verifies that the discovery schema (version 38→39 auto-migration) creates
43-
* the expected tables, supports CRUD operations, enforces foreign key cascade
44-
* behavior, and respects column defaults.
42+
* Verifies that the discovery schema (version 38→39 auto-migration) creates the expected tables, supports CRUD
43+
* operations, enforces foreign key cascade behavior, and respects column defaults.
4544
*/
4645
@RunWith(AndroidJUnit4::class)
4746
@Config(sdk = [34])
@@ -71,12 +70,13 @@ class DiscoveryMigrationTest {
7170

7271
@Test
7372
fun discoverySessionTable_insertAndRetrieve() = runTest {
74-
val session = DiscoverySessionEntity(
75-
timestamp = 1_000_000L,
76-
presetsScanned = "LONG_FAST,SHORT_FAST",
77-
homePreset = "LONG_FAST",
78-
completionStatus = "complete",
79-
)
73+
val session =
74+
DiscoverySessionEntity(
75+
timestamp = 1_000_000L,
76+
presetsScanned = "LONG_FAST,SHORT_FAST",
77+
homePreset = "LONG_FAST",
78+
completionStatus = "complete",
79+
)
8080
val id = discoveryDao.insertSession(session)
8181
assertTrue(id > 0, "Insert should return positive auto-generated ID")
8282
val loaded = discoveryDao.getSession(id)
@@ -88,14 +88,15 @@ class DiscoveryMigrationTest {
8888
@Test
8989
fun discoveryPresetResultTable_insertAndRetrieve() = runTest {
9090
val sessionId = discoveryDao.insertSession(testSession())
91-
val result = DiscoveryPresetResultEntity(
92-
sessionId = sessionId,
93-
presetName = "LONG_FAST",
94-
dwellDurationSeconds = 30,
95-
uniqueNodes = 5,
96-
directNeighborCount = 3,
97-
meshNeighborCount = 2,
98-
)
91+
val result =
92+
DiscoveryPresetResultEntity(
93+
sessionId = sessionId,
94+
presetName = "LONG_FAST",
95+
dwellDurationSeconds = 30,
96+
uniqueNodes = 5,
97+
directNeighborCount = 3,
98+
meshNeighborCount = 2,
99+
)
99100
val resultId = discoveryDao.insertPresetResult(result)
100101
assertTrue(resultId > 0)
101102
val results = discoveryDao.getPresetResults(sessionId)
@@ -108,17 +109,18 @@ class DiscoveryMigrationTest {
108109
fun discoveredNodeTable_insertAndRetrieve() = runTest {
109110
val sessionId = discoveryDao.insertSession(testSession())
110111
val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
111-
val node = DiscoveredNodeEntity(
112-
presetResultId = presetId,
113-
nodeNum = 12345,
114-
shortName = "TST",
115-
longName = "Test Node",
116-
neighborType = "direct",
117-
latitude = 37.7749,
118-
longitude = -122.4194,
119-
snr = 8.5f,
120-
rssi = -65,
121-
)
112+
val node =
113+
DiscoveredNodeEntity(
114+
presetResultId = presetId,
115+
nodeNum = 12345,
116+
shortName = "TST",
117+
longName = "Test Node",
118+
neighborType = "direct",
119+
latitude = 37.7749,
120+
longitude = -122.4194,
121+
snr = 8.5f,
122+
rssi = -65,
123+
)
122124
val nodeId = discoveryDao.insertDiscoveredNode(node)
123125
assertTrue(nodeId > 0)
124126
val nodes = discoveryDao.getDiscoveredNodes(presetId)
@@ -134,11 +136,7 @@ class DiscoveryMigrationTest {
134136
@Test
135137
fun sessionEntity_defaultValues() = runTest {
136138
// Insert with only required fields — verify defaults
137-
val session = DiscoverySessionEntity(
138-
timestamp = 1L,
139-
presetsScanned = "A",
140-
homePreset = "A",
141-
)
139+
val session = DiscoverySessionEntity(timestamp = 1L, presetsScanned = "A", homePreset = "A")
142140
val id = discoveryDao.insertSession(session)
143141
val loaded = discoveryDao.getSession(id)!!
144142
assertEquals(0, loaded.totalUniqueNodes)
@@ -263,4 +261,3 @@ class DiscoveryMigrationTest {
263261

264262
// endregion
265263
}
266-

feature/discovery/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,7 @@ kotlin {
5151
}
5252

5353
commonTest.dependencies { implementation(projects.core.testing) }
54+
55+
androidMain.dependencies { implementation(libs.mlkit.genai.prompt) }
5456
}
5557
}

feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,99 @@
1616
*/
1717
package org.meshtastic.feature.discovery.ai
1818

19+
import co.touchlab.kermit.Logger
20+
import com.google.mlkit.genai.prompt.Generation
21+
import com.google.mlkit.genai.prompt.GenerativeModel
22+
import com.google.mlkit.genai.prompt.TextPart
23+
import com.google.mlkit.genai.prompt.generateContentRequest
1924
import org.koin.core.annotation.Single
2025
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
2126
import org.meshtastic.core.database.entity.DiscoverySessionEntity
2227
import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
2328

24-
// TODO: Replace with real Gemini Nano on-device implementation once
25-
// `com.google.ai.edge:aicore` or `com.google.android.gms:play-services-generativeai`
26-
// is added to libs.versions.toml. The implementation should:
27-
// 1. Check model availability via GenerativeModel.isAvailable()
28-
// 2. Build a structured prompt with session metrics (nodes, utilization, presets)
29-
// 3. Call generateContent() with the prompt
30-
// 4. Fall back to the algorithmic generator on any error
31-
3229
/**
33-
* Android provider that will use Gemini Nano for on-device AI summaries.
30+
* Android provider that uses Gemini Nano via ML Kit GenAI Prompt API for on-device AI summaries.
3431
*
35-
* Currently delegates to [DiscoverySummaryGenerator] because the Gemini Nano SDK dependency is not yet in the version
36-
* catalog.
32+
* Falls back to [DiscoverySummaryGenerator] when:
33+
* - The on-device model is unavailable (unsupported hardware or not downloaded)
34+
* - Generation fails for any reason
3735
*/
3836
@Single(binds = [DiscoverySummaryAiProvider::class])
3937
class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider {
4038

41-
// Delegates to DiscoverySummaryGenerator (algorithmic) so results are always available.
42-
// When real Gemini Nano SDK is wired, this should check GenerativeModel.isAvailable() at runtime.
43-
override val isAvailable: Boolean = true
39+
private val log = Logger.withTag("GeminiNanoSummary")
40+
41+
private val generativeModel: GenerativeModel? by lazy {
42+
@Suppress("TooGenericExceptionCaught") // ML Kit throws undocumented RuntimeExceptions
43+
try {
44+
Generation.getClient()
45+
} catch (e: Exception) {
46+
log.w(e) { "Failed to get GenerativeModel client" }
47+
null
48+
}
49+
}
50+
51+
override val isAvailable: Boolean
52+
get() = checkAvailability()
4453

4554
override suspend fun generateSessionSummary(
4655
session: DiscoverySessionEntity,
4756
presetResults: List<DiscoveryPresetResultEntity>,
48-
): String = generator.generateSessionSummary(session, presetResults)
57+
): String {
58+
val model = generativeModel
59+
if (model == null || !isAvailable) {
60+
log.d { "Gemini Nano unavailable, using algorithmic fallback" }
61+
return generator.generateSessionSummary(session, presetResults)
62+
}
63+
64+
val prompt = generator.buildSessionPrompt(session, presetResults)
65+
return generateOrFallback(model, prompt) { generator.generateSessionSummary(session, presetResults) }
66+
}
67+
68+
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String {
69+
val model = generativeModel
70+
if (model == null || !isAvailable) {
71+
return generator.generatePresetSummary(result)
72+
}
73+
74+
val prompt = generator.buildPresetPrompt(result)
75+
return generateOrFallback(model, prompt) { generator.generatePresetSummary(result) }
76+
}
77+
78+
private suspend fun generateOrFallback(model: GenerativeModel, prompt: String, fallback: () -> String): String =
79+
try {
80+
val request =
81+
generateContentRequest(TextPart(prompt)) {
82+
temperature = TEMPERATURE
83+
topK = TOP_K
84+
maxOutputTokens = MAX_OUTPUT_TOKENS
85+
}
86+
val response = model.generateContent(request)
87+
val text = response.candidates.firstOrNull()?.text
88+
if (text.isNullOrBlank()) {
89+
log.w { "Gemini Nano returned empty response, using fallback" }
90+
fallback()
91+
} else {
92+
text
93+
}
94+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
95+
log.w(e) { "Gemini Nano generation failed, using fallback" }
96+
fallback()
97+
}
98+
99+
private fun checkAvailability(): Boolean = try {
100+
// FeatureStatus is an IntDef — check synchronously via the lazy model field.
101+
// Note: checkStatus() is suspend in the API; we use a non-suspend heuristic here
102+
// by catching and falling back if unavailable. The actual availability is confirmed
103+
// in generateOrFallback when the suspend call succeeds.
104+
generativeModel != null
105+
} catch (_: Exception) {
106+
false
107+
}
49108

50-
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String =
51-
generator.generatePresetSummary(result)
109+
private companion object {
110+
const val TEMPERATURE = 0.3f
111+
const val TOP_K = 16
112+
const val MAX_OUTPUT_TOKENS = 200
113+
}
52114
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ maps-compose = "8.3.0"
5858

5959
# ML Kit
6060
mlkit-barcode-scanning = "17.3.0"
61+
mlkit-genai-prompt = "1.0.0-beta2"
6162
mlkit-translate = "17.0.3"
6263

6364
# CameraX
@@ -178,6 +179,7 @@ maps-compose = { module = "com.google.maps.android:maps-compose", version.ref =
178179
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
179180
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
180181
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
182+
mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkit-genai-prompt" }
181183
mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" }
182184
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
183185
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }

0 commit comments

Comments
 (0)