Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,9 @@ tools/proot-fetch/
# set QAIRT_ROOT=C:\Qualcomm\AIStack\QAIRT
# ./gradlew :imagegen:syncQnnRuntimeLibs

# ---------------------------------------------------------------------------
# Env templates (placeholders only, no secrets) — keep tracked
# ---------------------------------------------------------------------------
!.env.example
!**/.env.example

13 changes: 13 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ android {
useSupportLibrary = true
}

// Phase 1 identity: private backend base URL (e.g. https://api.androllm.app).
// Fed by ANDROLLM_BACKEND_URL env / Gradle property; empty = no backend.
// Never put secrets here — only the public base URL.
val backendUrl = System.getenv("ANDROLLM_BACKEND_URL")
?: project.findProperty("ANDROLLM_BACKEND_URL")?.toString()
?: ""
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendUrl\"")

ndk {
abiFilters += listOf("arm64-v8a")
}
Expand Down Expand Up @@ -132,6 +140,7 @@ dependencies {
implementation(project(":core:models"))
implementation(project(":core:network"))
implementation(project(":core:cloud"))
implementation(project(":core:telemetry"))
implementation(project(":core:memory"))
implementation(project(":core:voice"))
implementation(project(":core:tools"))
Expand Down Expand Up @@ -191,6 +200,10 @@ dependencies {
ksp("com.google.dagger:hilt-android-compiler:2.57.1")
implementation(libs.androidx.hilt)
ksp("androidx.hilt:hilt-compiler:1.2.0")

// WorkManager (analytics sync worker; same artifacts as core:memory)
implementation("androidx.hilt:hilt-work:1.2.0")
implementation("androidx.work:work-runtime-ktx:2.9.1")

// Room
implementation(libs.room.runtime)
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/io/androllm/app/AndroLLMApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ class AndroLLMApplication : Application(), WorkConfiguration.Provider {
@Inject
lateinit var memoryBackgroundScheduler: MemoryBackgroundScheduler

@Inject
lateinit var analyticsSyncScheduler: io.androllm.app.sync.AnalyticsSyncScheduler

@Inject
lateinit var analyticsSyncTrigger: io.androllm.app.sync.AnalyticsSyncTrigger

override val workManagerConfiguration: WorkConfiguration
get() = WorkConfiguration.Builder().setWorkerFactory(workerFactory).build()

Expand Down Expand Up @@ -67,6 +73,15 @@ class AndroLLMApplication : Application(), WorkConfiguration.Provider {
// memoryBackgroundScheduler.schedule() deferred to MemoryRepository first write
// Keep NPU env seeding on critical path (must be before first dlopen) — already done above

// Analytics sync — cheap unique-work enqueue (KEEP); the worker itself
// no-ops unless signed in AND web-connected. No data leaves the device before approval.
runCatching { analyticsSyncScheduler.schedule() }
.onFailure { Timber.w("[AnalyticsSync] schedule failed: ${it.message}") }
// Near-real-time path: each recorded request/generation enqueues a
// debounced upload (~8s), so the dashboard reflects usage in <10s.
runCatching { analyticsSyncTrigger.bind() }
.onFailure { Timber.w("[AnalyticsSync] trigger bind failed: ${it.message}") }

registerComponentCallbacks(object : ComponentCallbacks2 {
override fun onTrimMemory(level: Int) {
when (level) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.androllm.app.identity

import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume

/**
* Phase 1 — Firebase ID token source for backend calls.
*
* Wraps `FirebaseAuth.currentUser.getIdToken()` in a suspend function so
* [io.androllm.core.network.identity.IdentityApi] stays Firebase-free.
* Returns null when signed out or when Firebase is unavailable (guest mode).
*/
@Singleton
class FirebaseIdTokenProvider @Inject constructor() {

suspend fun getToken(forceRefresh: Boolean = false): String? {
val auth = runCatching { FirebaseAuth.getInstance() }.getOrNull() ?: return null
val user = auth.currentUser ?: return null
return try {
suspendCancellableCoroutine { cont ->
user.getIdToken(forceRefresh)
.addOnSuccessListener { result ->
cont.resume(result.token)
}
.addOnFailureListener { e ->
Timber.w(e, "[Identity] getIdToken failed")
cont.resume(null)
}
}
} catch (e: Exception) {
Timber.w(e, "[Identity] getIdToken threw")
null
}
}

fun isSignedIn(): Boolean =
runCatching { FirebaseAuth.getInstance().currentUser != null }.getOrDefault(false)
}
60 changes: 60 additions & 0 deletions app/src/main/java/io/androllm/app/identity/IdentityModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.androllm.app.identity

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.androllm.app.BuildConfig
import io.androllm.core.network.identity.IdentityApi
import io.androllm.core.network.identity.SyncApi
import io.ktor.client.HttpClient
import javax.inject.Singleton

/**
* Phase 1 — wires [IdentityApi] to the private backend.
*
* Base URL comes from `BuildConfig.BACKEND_BASE_URL`, which is fed by the
* `ANDROLLM_BACKEND_URL` environment variable / Gradle property at build time
* (see `app/build.gradle.kts`). Empty means "no backend configured" — the app
* works fully offline/guest and every [IdentityApi] call returns Unauthorized
* without hitting the network.
*/
@Module
@InstallIn(SingletonComponent::class)
object IdentityModule {

@Provides
@Singleton
fun provideIdentityApi(
httpClient: HttpClient,
tokenProvider: FirebaseIdTokenProvider,
): IdentityApi {
val baseUrl = BuildConfig.BACKEND_BASE_URL.trim()
return IdentityApi(
baseUrl = baseUrl.ifBlank { "https://localhost-disabled.invalid" },
httpClient = httpClient,
idTokenProvider = { forceRefresh -> tokenProvider.getToken(forceRefresh) },
isConfigured = baseUrl.isNotBlank(),
)
}

/**
* Phase 3 (revised) — usage/snapshot ingestion client. Same base URL and
* token source as [IdentityApi]; callers must gate on
* `UserProfile.canSyncUsage` (the worker verifies via `GET /me`).
*/
@Provides
@Singleton
fun provideSyncApi(
httpClient: HttpClient,
tokenProvider: FirebaseIdTokenProvider,
): SyncApi {
val baseUrl = BuildConfig.BACKEND_BASE_URL.trim()
return SyncApi(
baseUrl = baseUrl.ifBlank { "https://localhost-disabled.invalid" },
httpClient = httpClient,
idTokenProvider = { forceRefresh -> tokenProvider.getToken(forceRefresh) },
isConfigured = baseUrl.isNotBlank(),
)
}
}
176 changes: 176 additions & 0 deletions app/src/main/java/io/androllm/app/sync/AnalyticsMapper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package io.androllm.app.sync

import io.androllm.core.cloud.usage.CloudRequestKind
import io.androllm.core.cloud.usage.CloudUsageRecord
import io.androllm.core.cloud.usage.CloudUsageSnapshot
import io.androllm.core.network.identity.SourcePage
import io.androllm.core.network.identity.SyncEvent
import io.androllm.core.telemetry.DeviceMetrics
import io.androllm.core.telemetry.GenerationStat
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
* Phase 3 (revised) — maps the app's existing analytics surfaces onto the
* backend sync payloads. Pure functions (no Android framework beyond data
* classes) so the mapping rules are unit-testable.
*
* Sources (UI untouched — only the data these screens already render):
* - Cloud usage dashboard → [CloudUsageRecord] (stable UUID ids, persisted ring)
* - Developer page → [GenerationStat] (in-memory generation history)
*/

private fun Long.coerceToApiInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()

private fun Long.toIsoUtc(): String = Instant.ofEpochMilli(this).toString()

private val HOUR_BUCKET_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HH").withZone(ZoneOffset.UTC)

/** Stable hourly bucket (`2026-09-21-14`) — retries within the hour reuse the snapshot id. */
fun snapshotBucket(sourcePage: String, nowMs: Long): String =
"$sourcePage:${HOUR_BUCKET_FORMAT.format(Instant.ofEpochMilli(nowMs))}"

fun cloudEventType(kind: CloudRequestKind): String = when (kind) {
CloudRequestKind.CHAT -> "chat_completed"
CloudRequestKind.EMBEDDING -> "embedding_completed"
CloudRequestKind.HEALTH -> "health_check"
CloudRequestKind.MODELS -> "models_listed"
}

/** One cloud dashboard record → one sync event. Stable id = record.id (dedup key). */
fun CloudUsageRecord.toSyncEvent(): SyncEvent = SyncEvent(
eventId = id,
eventType = cloudEventType(kind),
sessionId = null, // Local conversation ids are not backend sessions; attaching them would reject.
sourcePage = SourcePage.CLOUD_USAGE_DASHBOARD,
modelName = modelId.ifBlank { null },
providerName = providerName.ifBlank { null },
engineType = "cloud",
tokensInput = inputTokens.coerceToApiInt(),
tokensOutput = outputTokens.coerceToApiInt(),
totalTokens = totalTokens.coerceToApiInt(),
latencyMs = latencyMs.coerceAtLeast(0L),
timeToFirstTokenMs = firstTokenMs?.coerceAtLeast(0L),
success = success,
errorCode = errorKind.name.takeIf { it != "NONE" },
errorMessage = errorMessage.take(2000).ifBlank { null },
metadata = buildJsonObject {
put("kind", kind.name)
put("cachedTokens", cachedTokens)
put("estimatedCostMicros", estimatedCostMicros)
put("retryCount", retryCount)
put("usedFallbackProvider", usedFallbackProvider)
put("cacheHit", cacheHit)
put("cacheSavedTokens", cacheSavedTokens)
put("toolCallsCount", toolCallsCount)
put("finishReason", finishReason)
put("streamed", streamed)
},
createdAt = timestampMs.toIsoUtc(),
)

/** Deterministic dedup key for a local generation (stable across worker retries). */
fun generationKey(stat: GenerationStat): String =
"devgen-${stat.timestampMs}-${stat.modelName.hashCode()}-${stat.totalTokens}"

/**
* One developer-page generation → one sync event, counted as a local chat so
* the website dashboard accrues local tokens. A generation that produced
* output counts as success; anything else preserves the stop reason.
*/
fun GenerationStat.toSyncEvent(): SyncEvent {
val succeeded = generatedTokens > 0
return SyncEvent(
eventId = generationKey(this),
eventType = "chat_completed",
sessionId = null,
sourcePage = SourcePage.DEVELOPER_PAGE,
modelName = modelName.ifBlank { null },
providerName = null,
engineType = "local",
tokensInput = promptTokens.coerceToApiInt(),
tokensOutput = generatedTokens.coerceToApiInt(),
totalTokens = totalTokens.coerceToApiInt(),
latencyMs = totalTimeMs.coerceAtLeast(0L),
timeToFirstTokenMs = firstTokenMs.coerceAtLeast(0L),
success = succeeded,
errorCode = if (succeeded) null else stopReason.take(128).ifBlank { "no_output" },
errorMessage = null,
metadata = buildJsonObject {
put("tokensPerSecond", tokensPerSecond)
put("stopReason", stopReason)
},
createdAt = timestampMs.toIsoUtc(),
)
}

/**
* Page-level cloud dashboard rollup. Raw records travel as events, so the
* history list is excluded here to keep the payload small.
*/
fun buildCloudSnapshotPayload(snapshot: CloudUsageSnapshot): JsonObject = buildJsonObject {
put("generatedAtMs", snapshot.generatedAtMs)
put("currentProviderId", snapshot.currentProviderId)
put("currentProviderName", snapshot.currentProviderName)
put("currentModelId", snapshot.currentModelId)
put("activeSessions", snapshot.activeSessions)
putJsonTotals("total", snapshot.total.requests, snapshot.total.inputTokens, snapshot.total.outputTokens,
snapshot.total.totalTokens, snapshot.total.successes, snapshot.total.failures,
snapshot.total.avgLatencyMs, snapshot.total.avgFirstTokenMs, snapshot.total.estimatedCostMicros)
putJsonTotals("today", snapshot.today.requests, snapshot.today.inputTokens, snapshot.today.outputTokens,
snapshot.today.totalTokens, snapshot.today.successes, snapshot.today.failures,
snapshot.today.avgLatencyMs, snapshot.today.avgFirstTokenMs, snapshot.today.estimatedCostMicros)
put("alertCount", snapshot.alerts.size)
}

/** Page-level developer telemetry summary. */
fun buildDeveloperSnapshotPayload(
generations: List<GenerationStat>,
deviceMetrics: DeviceMetrics?,
currentModel: String,
): JsonObject = buildJsonObject {
put("generationCount", generations.size)
put("totalTokens", generations.sumOf { it.totalTokens })
val tps = generations.map { it.tokensPerSecond }.filter { it > 0 }
put("avgTokensPerSecond", if (tps.isEmpty()) 0.0 else tps.average())
val lat = generations.map { it.totalTimeMs }.filter { it > 0 }
put("avgLatencyMs", if (lat.isEmpty()) 0 else lat.average().toLong())
put("currentModel", currentModel)
if (deviceMetrics != null) {
put("deviceModel", deviceMetrics.deviceModel)
put("totalRamMb", deviceMetrics.totalRamMb)
put("usedRamMb", deviceMetrics.usedRamMb)
put("cpuCores", deviceMetrics.cpuCores)
put("freeStorageBytes", deviceMetrics.freeStorageBytes)
}
}

private fun kotlinx.serialization.json.JsonObjectBuilder.putJsonTotals(
key: String,
requests: Int,
inputTokens: Long,
outputTokens: Long,
totalTokens: Long,
successes: Int,
failures: Int,
avgLatencyMs: Long,
avgFirstTokenMs: Long,
estimatedCostMicros: Long,
) {
put(key, buildJsonObject {
put("requests", requests)
put("inputTokens", inputTokens)
put("outputTokens", outputTokens)
put("totalTokens", totalTokens)
put("successes", successes)
put("failures", failures)
put("avgLatencyMs", avgLatencyMs)
put("avgFirstTokenMs", avgFirstTokenMs)
put("estimatedCostMicros", estimatedCostMicros)
})
}
Loading
Loading