diff --git a/app-release-signed.apk b/app-release-signed.apk deleted file mode 100644 index 6a0b469..0000000 Binary files a/app-release-signed.apk and /dev/null differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 31b2915..2e1190d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,8 @@ dependencies { debugImplementation("androidx.compose.ui:ui-test-manifest") implementation("com.google.ai.client.generativeai:generativeai:0.9.0") + implementation("com.google.mediapipe:tasks-genai:0.10.27") + implementation("com.google.mediapipe:tasks-vision:0.10.14") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") } diff --git a/app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt b/app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt index 83e39b8..d51e50c 100644 --- a/app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt +++ b/app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt @@ -22,11 +22,12 @@ import androidx.compose.ui.window.Dialog fun ApiKeyDialog( apiKeyManager: ApiKeyManager, isFirstLaunch: Boolean = false, + initialProvider: ApiProvider? = null, onDismiss: () -> Unit ) { var apiKeyInput by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") } - var selectedProvider by remember { mutableStateOf(ApiProvider.VERCEL) } + var selectedProvider by remember { mutableStateOf(initialProvider ?: ApiProvider.VERCEL) } val apiKeys = remember { mutableStateMapOf>() } var selectedKeyIndex by remember { mutableStateOf(apiKeyManager.getCurrentKeyIndex(selectedProvider)) } val context = LocalContext.current @@ -43,11 +44,7 @@ fun ApiKeyDialog( loadKeysForProvider(ApiProvider.CEREBRAS) } - Dialog(onDismissRequest = { - if (!isFirstLaunch || (apiKeys[ApiProvider.GOOGLE]?.isNotEmpty() == true || apiKeys[ApiProvider.CEREBRAS]?.isNotEmpty() == true)) { - onDismiss() - } - }) { + Dialog(onDismissRequest = onDismiss) { Surface( modifier = Modifier .fillMaxWidth() @@ -89,6 +86,7 @@ fun ApiKeyDialog( ApiProvider.GOOGLE -> "https://makersuite.google.com/app/apikey" ApiProvider.CEREBRAS -> "https://cloud.cerebras.ai/" ApiProvider.VERCEL -> "https://vercel.com/ai-gateway" + ApiProvider.OFFLINE_GEMMA -> "https://ai.google.dev/edge/gallery" } val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) context.startActivity(intent) @@ -194,10 +192,8 @@ fun ApiKeyDialog( .padding(top = 16.dp), horizontalArrangement = Arrangement.End ) { - if (!isFirstLaunch || (apiKeys[ApiProvider.VERCEL]?.isNotEmpty() == true || apiKeys[ApiProvider.GOOGLE]?.isNotEmpty() == true || apiKeys[ApiProvider.CEREBRAS]?.isNotEmpty() == true)) { - TextButton(onClick = onDismiss) { - Text("Close") - } + TextButton(onClick = onDismiss) { + Text("Close") } } } diff --git a/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt b/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt index cc9628b..5f66a42 100644 --- a/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt +++ b/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt @@ -14,7 +14,8 @@ import com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel enum class ApiProvider { VERCEL, GOOGLE, - CEREBRAS + CEREBRAS, + OFFLINE_GEMMA } enum class ModelOption(val displayName: String, val modelName: String, val apiProvider: ApiProvider = ApiProvider.GOOGLE) { @@ -30,7 +31,8 @@ enum class ModelOption(val displayName: String, val modelName: String, val apiPr GEMINI_FLASH("Gemini 2.0 Flash", "gemini-2.0-flash"), GEMINI_FLASH_LITE("Gemini 2.0 Flash Lite", "gemini-2.0-flash-lite"), GEMMA_3_27B_IT("Gemma 3 27B IT", "gemma-3-27b-it"), - GEMMA_3N_E4B_IT("Gemma 3n E4B it (online)", "gemma-3n-e4b-it") + GEMMA_3N_E4B_IT("Gemma 3n E4B it (online)", "gemma-3n-e4b-it"), + GEMMA_3N_E4B_IT_OFFLINE("Gemma 3n E4B it (offline GPU)", "gemma-3n-e4b-it-offline", ApiProvider.OFFLINE_GEMMA) } val GenerativeViewModelFactory = object : ViewModelProvider.Factory { @@ -48,9 +50,14 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory { // Get the API key from MainActivity val mainActivity = MainActivity.getInstance() val currentModel = GenerativeAiViewModelFactory.getCurrentModel() - val apiKey = mainActivity?.getCurrentApiKey(currentModel.apiProvider) ?: "" - if (apiKey.isEmpty()) { + val apiKey = if (currentModel.apiProvider == ApiProvider.OFFLINE_GEMMA) { + "OFFLINE" + } else { + mainActivity?.getCurrentApiKey(currentModel.apiProvider) ?: "" + } + + if (apiKey.isEmpty() && currentModel.apiProvider != ApiProvider.OFFLINE_GEMMA) { throw IllegalStateException("API key for ${currentModel.apiProvider} is not available. Please set an API key.") } @@ -59,7 +66,21 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory { isAssignableFrom(PhotoReasoningViewModel::class.java) -> { val currentModel = GenerativeAiViewModelFactory.getCurrentModel() - if (currentModel.modelName.contains("live")) { + if (currentModel.apiProvider == ApiProvider.OFFLINE_GEMMA) { + // Offline Gemma model + // We use a dummy GenerativeModel because PhotoReasoningViewModel expects one + // but it will use MediaPipe for actual inference. + val dummyModel = GenerativeModel( + modelName = currentModel.modelName, + apiKey = "OFFLINE", + generationConfig = config + ) + PhotoReasoningViewModel( + dummyModel, + currentModel.modelName, + null + ) + } else if (currentModel.modelName.contains("live")) { // Live API models val liveApiManager = LiveApiManager(apiKey, currentModel.modelName) diff --git a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt index 9d77d78..11f576a 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MainActivity.kt @@ -103,6 +103,7 @@ class MainActivity : ComponentActivity() { private var photoReasoningViewModel: PhotoReasoningViewModel? = null private lateinit var apiKeyManager: ApiKeyManager private var showApiKeyDialog by mutableStateOf(false) + private var selectedProviderForDialog by mutableStateOf(null) // Google Play Billing private lateinit var billingClient: BillingClient @@ -381,12 +382,6 @@ class MainActivity : ComponentActivity() { apiKeyManager = ApiKeyManager.getInstance(this) Log.d(TAG, "onCreate: ApiKeyManager initialized.") - if (apiKeyManager.getApiKeys(ApiProvider.GOOGLE).isEmpty() && apiKeyManager.getApiKeys(ApiProvider.CEREBRAS).isEmpty()) { - showApiKeyDialog = true - Log.d(TAG, "onCreate: No API key found, showApiKeyDialog set to true.") - } else { - Log.d(TAG, "onCreate: API key found.") - } // Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") // Deleted // checkAndRequestPermissions() // Deleted @@ -548,9 +543,11 @@ class MainActivity : ComponentActivity() { ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys(ApiProvider.GOOGLE).isEmpty() && apiKeyManager.getApiKeys(ApiProvider.CEREBRAS).isEmpty(), + initialProvider = selectedProviderForDialog, onDismiss = { Log.d(TAG, "ApiKeyDialog onDismiss called.") showApiKeyDialog = false + selectedProviderForDialog = null } ) } else { @@ -736,8 +733,9 @@ class MainActivity : ComponentActivity() { Log.w(TAG, "MenuScreen: Navigation to '$routeId' blocked due to trial state.") } }, - onApiKeyButtonClicked = { - Log.d(TAG, "MenuScreen onApiKeyButtonClicked: Showing ApiKeyDialog.") + onApiKeyButtonClicked = { provider -> + Log.d(TAG, "MenuScreen onApiKeyButtonClicked: Showing ApiKeyDialog for $provider.") + selectedProviderForDialog = provider showApiKeyDialog = true }, onDonationButtonClicked = { diff --git a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt index d31154e..760d5ee 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt @@ -41,6 +41,7 @@ import android.Manifest // For Manifest.permission.POST_NOTIFICATIONS import androidx.compose.material3.AlertDialog // For the rationale dialog import androidx.compose.runtime.saveable.rememberSaveable import android.util.Log +import com.google.ai.sample.util.ModelDownloadManager data class MenuItem( val routeId: String, @@ -52,7 +53,7 @@ data class MenuItem( fun MenuScreen( innerPadding: PaddingValues, onItemClicked: (String) -> Unit = { }, - onApiKeyButtonClicked: () -> Unit = { }, + onApiKeyButtonClicked: (ApiProvider?) -> Unit = { }, onDonationButtonClicked: () -> Unit = { }, isTrialExpired: Boolean = false, // New parameter to indicate trial status isPurchased: Boolean = false @@ -67,6 +68,7 @@ fun MenuScreen( val currentModel = GenerativeAiViewModelFactory.getCurrentModel() var selectedModel by remember { mutableStateOf(currentModel) } var expanded by remember { mutableStateOf(false) } + var showDownloadDialog by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -95,7 +97,7 @@ fun MenuScreen( modifier = Modifier.weight(1f) ) Button( - onClick = { onApiKeyButtonClicked() }, + onClick = { onApiKeyButtonClicked(null) }, enabled = true, // Always enabled modifier = Modifier.padding(start = 8.dp) ) { @@ -157,6 +159,12 @@ fun MenuScreen( selectedModel = modelOption GenerativeAiViewModelFactory.setModel(modelOption) expanded = false + + if (modelOption.apiProvider == ApiProvider.OFFLINE_GEMMA) { + if (!ModelDownloadManager.isModelDownloaded(context)) { + showDownloadDialog = true + } + } }, enabled = true // Always enabled ) @@ -193,6 +201,21 @@ fun MenuScreen( if (isTrialExpired) { Toast.makeText(context, "Please subscribe to the app to continue.", Toast.LENGTH_LONG).show() } else { + val currentModelOption = GenerativeAiViewModelFactory.getCurrentModel() + if (currentModelOption.apiProvider == ApiProvider.OFFLINE_GEMMA) { + if (!ModelDownloadManager.isModelDownloaded(context)) { + showDownloadDialog = true + return@TextButton + } + } else { + val mainActivity = context as? MainActivity + val apiKey = mainActivity?.getCurrentApiKey(currentModelOption.apiProvider) + if (apiKey.isNullOrEmpty()) { + onApiKeyButtonClicked(currentModelOption.apiProvider) + return@TextButton + } + } + if (menuItem.routeId == "photo_reasoning") { val mainActivity = context as? MainActivity if (mainActivity != null) { // Ensure mainActivity is not null @@ -314,6 +337,29 @@ GPT-5 nano Input: $0.05/M Output: $0.40/M } } + if (showDownloadDialog) { + AlertDialog( + onDismissRequest = { showDownloadDialog = false }, + title = { Text("Download Model") }, + text = { + val availableSpace = "%.2f".format(ModelDownloadManager.getAvailableStorageGB(context)) + Text("Should Gemma 3n E4B be downloaded? It requires 4.7 GB.\n\nAvailable storage: $availableSpace GB") + }, + confirmButton = { + TextButton( + onClick = { + ModelDownloadManager.downloadModel(context) + showDownloadDialog = false + Toast.makeText(context, "Download started...", Toast.LENGTH_SHORT).show() + } + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showDownloadDialog = false }) { Text("Abort") } + } + ) + } + if (showRationaleDialogForPhotoReasoning) { val mainActivity = LocalContext.current as? MainActivity AlertDialog( diff --git a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt index 17f356e..4d9f099 100644 --- a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt @@ -27,11 +27,16 @@ import android.view.WindowManager import android.widget.Toast import com.google.ai.client.generativeai.GenerativeModel import com.google.ai.client.generativeai.type.Content -import com.google.ai.client.generativeai.type.ImagePart // For instance check +import com.google.ai.client.generativeai.type.ImagePart import com.google.ai.client.generativeai.type.FunctionCallPart // For logging AI response import com.google.ai.client.generativeai.type.FunctionResponsePart // For logging AI response import com.google.ai.client.generativeai.type.BlobPart // For logging AI response import com.google.ai.client.generativeai.type.TextPart // For logging AI response +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession +import com.google.mediapipe.tasks.genai.llminference.GraphOptions +import com.google.mediapipe.framework.image.BitmapImageBuilder +import com.google.ai.sample.util.ModelDownloadManager // Removed duplicate TextPart import import com.google.ai.sample.feature.multimodal.dtos.ContentDto import com.google.ai.sample.feature.multimodal.dtos.toSdk @@ -101,6 +106,8 @@ class ScreenCaptureService : Service() { private var isReady = false // Flag to indicate if MediaProjection is set up and active private val isScreenshotRequestedRef = java.util.concurrent.atomic.AtomicBoolean(false) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var llmInference: LlmInference? = null + private var llmSession: LlmInferenceSession? = null // Callback for MediaProjection private val mediaProjectionCallback = object : MediaProjection.Callback() { @@ -265,7 +272,11 @@ class ScreenCaptureService : Service() { } } try { - if (apiProvider == ApiProvider.VERCEL) { + if (apiProvider == ApiProvider.OFFLINE_GEMMA) { + val result = callOfflineGemmaApi(chatHistory, inputContent) + responseText = result.first + errorMessage = result.second + } else if (apiProvider == ApiProvider.VERCEL) { val result = callVercelApi(modelName, apiKey, chatHistory, inputContent) responseText = result.first errorMessage = result.second @@ -674,6 +685,11 @@ class ScreenCaptureService : Service() { mediaProjection?.unregisterCallback(mediaProjectionCallback) mediaProjection?.stop() mediaProjection = null + + llmSession?.close() + llmSession = null + llmInference?.close() + llmInference = null } catch (e: Exception) { Log.e(TAG, "Error during full cleanup", e) } finally { @@ -697,6 +713,93 @@ class ScreenCaptureService : Service() { } override fun onBind(intent: Intent?): IBinder? = null + + private fun callOfflineGemmaApi(chatHistory: List, inputContent: Content): Pair { + var responseText: String? = null + var errorMessage: String? = null + + try { + val session = getLlmSession() ?: return Pair(null, "Offline model not found or failed to initialize. Please download it first.") + + // Extract image if present + val bitmap = inputContent.parts.filterIsInstance().firstOrNull()?.image + + // Construct prompt from history and input + // For session-based API, we might want to add history as query chunks if session is new + // or just the latest input. + // Gemma 3 multimodal prompt usually includes token. + + val inputText = inputContent.parts.filterIsInstance().joinToString("\n") { it.text } + val imageToken = if (bitmap != null) "" else "" + val prompt = "user\n$imageToken$inputText\nmodel\n" + + Log.d(TAG, "Offline prompt: $prompt") + + if (bitmap != null) { + val mpImage = BitmapImageBuilder(bitmap).build() + session.addImage(mpImage) + } + session.addQueryChunk(prompt) + + responseText = session.generateResponse() + + // Broadcast the result as a stream chunk too, so the UI updates as if it was streaming + if (responseText != null) { + val streamIntent = Intent(ACTION_AI_STREAM_UPDATE).apply { + putExtra(EXTRA_AI_STREAM_CHUNK, responseText) + } + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(streamIntent) + } + } catch (e: Exception) { + Log.e(TAG, "Offline Gemma call failed", e) + errorMessage = e.localizedMessage ?: "Offline Gemma call failed" + } + + return Pair(responseText, errorMessage) + } + + private fun getLlmSession(): LlmInferenceSession? { + if (llmSession != null) return llmSession + + try { + val inference = getLlmInference() ?: return null + + val sessionOptions = LlmInferenceSession.LlmInferenceSessionOptions.builder() + .setTemperature(0.0f) + .setTopK(40) + .setGraphOptions(GraphOptions.builder() + .setEnableVisionModality(true) + .build()) + .build() + + llmSession = LlmInferenceSession.createFromOptions(inference, sessionOptions) + } catch (e: Exception) { + Log.e(TAG, "Failed to create LlmInferenceSession", e) + } + return llmSession + } + + private fun getLlmInference(): LlmInference? { + if (llmInference != null) return llmInference + + try { + val modelFile = ModelDownloadManager.getModelFile(applicationContext) + if (!modelFile.exists()) { + return null + } + + val options = LlmInference.LlmInferenceOptions.builder() + .setModelPath(modelFile.absolutePath) + .setMaxTopK(40) + .setMaxNumImages(10) + .build() + + llmInference = LlmInference.createFromOptions(applicationContext, options) + } catch (e: Exception) { + Log.e(TAG, "Failed to create LlmInference", e) + } + return llmInference + } } // Data classes for Vercel API diff --git a/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt b/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt index e12d751..90f9505 100644 --- a/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt @@ -83,6 +83,12 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -267,6 +273,28 @@ fun PhotoReasoningScreen( var entryToEdit: SystemMessageEntry? by rememberSaveable(stateSaver = SystemMessageEntrySaver) { mutableStateOf(null) } val listState = rememberLazyListState() val context = LocalContext.current + + val isScrolling = listState.isScrollInProgress + var lastScrollTime by remember { mutableLongStateOf(0L) } + LaunchedEffect(isScrolling) { + if (isScrolling) { + lastScrollTime = System.currentTimeMillis() + } + } + var showScrollbar by remember { mutableStateOf(false) } + LaunchedEffect(isScrolling, lastScrollTime) { + if (isScrolling) { + showScrollbar = true + } else { + kotlinx.coroutines.delay(1000) + showScrollbar = false + } + } + val scrollbarAlpha by animateFloatAsState( + targetValue = if (showScrollbar) 0.5f else 0f, + animationSpec = tween(durationMillis = 500), + label = "scrollbarAlpha" + ) var systemMessageEntries by rememberSaveable { mutableStateOf(emptyList()) } val focusManager = LocalFocusManager.current val messages by chatMessages.collectAsState() @@ -408,7 +436,47 @@ fun PhotoReasoningScreen( } } - LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .drawWithContent { + drawContent() + if (scrollbarAlpha > 0f) { + val layoutInfo = listState.layoutInfo + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isNotEmpty()) { + val totalItemsCount = layoutInfo.totalItemsCount + val viewportHeight = size.height + + val firstVisibleItem = visibleItemsInfo.first() + val lastVisibleItem = visibleItemsInfo.last() + + val visibleItemsCount = visibleItemsInfo.size + val scrollbarHeight = (visibleItemsCount.toFloat() / totalItemsCount) * viewportHeight + + val firstItemIndex = firstVisibleItem.index + val firstItemOffset = listState.firstVisibleItemScrollOffset.toFloat() + val firstItemHeight = firstVisibleItem.size.toFloat() + + val scrollPercentage = if (totalItemsCount > visibleItemsCount) { + (firstItemIndex + firstItemOffset / firstItemHeight) / totalItemsCount + } else { + 0f + } + + val scrollbarY = scrollPercentage * viewportHeight + + drawRect( + color = Color.Gray.copy(alpha = scrollbarAlpha), + topLeft = Offset(size.width - 4.dp.toPx(), scrollbarY), + size = Size(4.dp.toPx(), scrollbarHeight.coerceAtLeast(20.dp.toPx())) + ) + } + } + } + ) { items(messages) { message -> when (message.participant) { PhotoParticipant.USER -> UserChatBubble(message.text, message.isPending, message.imageUris) diff --git a/app/src/main/kotlin/com/google/ai/sample/util/ModelDownloadManager.kt b/app/src/main/kotlin/com/google/ai/sample/util/ModelDownloadManager.kt new file mode 100644 index 0000000..716dc9e --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/sample/util/ModelDownloadManager.kt @@ -0,0 +1,44 @@ +package com.google.ai.sample.util + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.os.StatFs +import java.io.File + +object ModelDownloadManager { + // Link to Gemma 3 4B it (Multimodal) from Google AI Edge + const val MODEL_URL = "https://storage.googleapis.com/mediapipe-models/llm/gemma-3n-4b-it-gpu.bin" + const val MODEL_FILE_NAME = "gemma-3n-4b-it-gpu.bin" + const val MODEL_SIZE_BYTES = 4_700_000_000L // 4.7 GB + + fun isModelDownloaded(context: Context): Boolean { + val file = File(context.getExternalFilesDir(null), MODEL_FILE_NAME) + return file.exists() && file.length() > 1_000_000 // Simple check if it's at least 1MB + } + + fun getModelFile(context: Context): File { + return File(context.getExternalFilesDir(null), MODEL_FILE_NAME) + } + + fun getAvailableStorageGB(context: Context): Double { + val path = context.getExternalFilesDir(null) ?: return 0.0 + val stat = StatFs(path.path) + val bytesAvailable = stat.availableBlocksLong * stat.blockSizeLong + return bytesAvailable / (1024.0 * 1024.0 * 1024.0) + } + + fun downloadModel(context: Context): Long { + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(Uri.parse(MODEL_URL)) + .setTitle("Downloading Gemma 3n E4B") + .setDescription("Downloading offline AI model (4.7 GB)") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationInExternalFilesDir(context, null, MODEL_FILE_NAME) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + + return downloadManager.enqueue(request) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9a4caa0..c0ad698 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ buildscript { } // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.1.3" apply false + id("com.android.application") version "8.2.2" apply false id("org.jetbrains.kotlin.android") version "1.9.20" apply false id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" apply false }