diff --git a/app-release-signed.apk b/app-release-signed.apk index 6a0b469..2957904 100644 Binary files a/app-release-signed.apk and b/app-release-signed.apk differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 31b2915..9fb9228 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,11 +36,11 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "21" } buildFeatures { compose = true @@ -89,6 +89,7 @@ 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("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..e7fdab0 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 = ApiProvider.VERCEL, 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) } val apiKeys = remember { mutableStateMapOf>() } var selectedKeyIndex by remember { mutableStateOf(apiKeyManager.getCurrentKeyIndex(selectedProvider)) } val context = LocalContext.current @@ -44,9 +45,7 @@ fun ApiKeyDialog( } Dialog(onDismissRequest = { - if (!isFirstLaunch || (apiKeys[ApiProvider.GOOGLE]?.isNotEmpty() == true || apiKeys[ApiProvider.CEREBRAS]?.isNotEmpty() == true)) { - onDismiss() - } + onDismiss() }) { Surface( modifier = Modifier @@ -89,6 +88,7 @@ fun ApiKeyDialog( ApiProvider.GOOGLE -> "https://makersuite.google.com/app/apikey" ApiProvider.CEREBRAS -> "https://cloud.cerebras.ai/" ApiProvider.VERCEL -> "https://vercel.com/ai-gateway" + else -> "" } val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) context.startActivity(intent) @@ -194,10 +194,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..6adde55 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,13 @@ 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) ?: "" + val apiKey = if (currentModel.apiProvider == ApiProvider.OFFLINE_GEMMA) { + "OFFLINE" // Dummy key for offline model + } else { + mainActivity?.getCurrentApiKey(currentModel.apiProvider) ?: "" + } - if (apiKey.isEmpty()) { + if (apiKey.isEmpty() && currentModel.apiProvider != ApiProvider.OFFLINE_GEMMA) { throw IllegalStateException("API key for ${currentModel.apiProvider} is not available. Please set an API key.") } @@ -58,8 +64,21 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory { when { isAssignableFrom(PhotoReasoningViewModel::class.java) -> { val currentModel = GenerativeAiViewModelFactory.getCurrentModel() - - if (currentModel.modelName.contains("live")) { + + if (currentModel.apiProvider == ApiProvider.OFFLINE_GEMMA) { + // For offline models, we use a dummy GenerativeModel + // The actual inference is handled in ScreenCaptureService via MediaPipe + 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..564f821 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 initialApiKeyProvider by mutableStateOf(ApiProvider.VERCEL) // Google Play Billing private lateinit var billingClient: BillingClient @@ -317,6 +318,11 @@ class MainActivity : ComponentActivity() { } } + fun showApiKeyDialogWithProvider(provider: ApiProvider) { + initialApiKeyProvider = provider + showApiKeyDialog = true + } + fun getCurrentApiKey(provider: ApiProvider): String? { val key = if (::apiKeyManager.isInitialized) { apiKeyManager.getCurrentApiKey(provider) @@ -381,12 +387,7 @@ 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.") - } + // Removed auto-show of ApiKeyDialog on first launch // Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") // Deleted // checkAndRequestPermissions() // Deleted @@ -548,6 +549,7 @@ class MainActivity : ComponentActivity() { ApiKeyDialog( apiKeyManager = apiKeyManager, isFirstLaunch = apiKeyManager.getApiKeys(ApiProvider.GOOGLE).isEmpty() && apiKeyManager.getApiKeys(ApiProvider.CEREBRAS).isEmpty(), + initialProvider = initialApiKeyProvider, onDismiss = { Log.d(TAG, "ApiKeyDialog onDismiss called.") showApiKeyDialog = false 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..44307ad 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,10 @@ 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 +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import androidx.compose.material3.LinearProgressIndicator data class MenuItem( val routeId: String, @@ -67,6 +71,10 @@ fun MenuScreen( val currentModel = GenerativeAiViewModelFactory.getCurrentModel() var selectedModel by remember { mutableStateOf(currentModel) } var expanded by remember { mutableStateOf(false) } + var showDownloadDialog by remember { mutableStateOf(false) } + var isDownloading by remember { mutableStateOf(false) } + var downloadProgress by remember { mutableStateOf(0f) } + val scope = rememberCoroutineScope() Column( modifier = Modifier @@ -154,6 +162,15 @@ fun MenuScreen( DropdownMenuItem( text = { Text(modelOption.displayName) }, onClick = { + if (modelOption.apiProvider == ApiProvider.OFFLINE_GEMMA) { + if (!ModelDownloadManager.isModelDownloaded(context)) { + selectedModel = modelOption + GenerativeAiViewModelFactory.setModel(modelOption) + showDownloadDialog = true + expanded = false + return@DropdownMenuItem + } + } selectedModel = modelOption GenerativeAiViewModelFactory.setModel(modelOption) expanded = false @@ -193,8 +210,25 @@ fun MenuScreen( if (isTrialExpired) { Toast.makeText(context, "Please subscribe to the app to continue.", Toast.LENGTH_LONG).show() } else { + val currentModelOption = GenerativeAiViewModelFactory.getCurrentModel() + val mainActivity = context as? MainActivity + + // Check for API key if not offline + if (currentModelOption.apiProvider != ApiProvider.OFFLINE_GEMMA) { + val apiKey = mainActivity?.getCurrentApiKey(currentModelOption.apiProvider) + if (apiKey.isNullOrEmpty()) { + mainActivity?.showApiKeyDialogWithProvider(currentModelOption.apiProvider) + return@TextButton + } + } else { + // Offline model - check if downloaded + if (!ModelDownloadManager.isModelDownloaded(context)) { + showDownloadDialog = true + return@TextButton + } + } + if (menuItem.routeId == "photo_reasoning") { - val mainActivity = context as? MainActivity if (mainActivity != null) { // Ensure mainActivity is not null if (!mainActivity.isNotificationPermissionGranted()) { Log.d("MenuScreen", "Notification permission NOT granted.") @@ -314,6 +348,67 @@ GPT-5 nano Input: $0.05/M Output: $0.40/M } } + if (showDownloadDialog) { + val availableMB = ModelDownloadManager.getAvailableExternalStorage(context) / (1024 * 1024) + val availableGB = availableMB / 1024.0 + + AlertDialog( + onDismissRequest = { if (!isDownloading) showDownloadDialog = false }, + title = { Text("Download Gemma 3n E4B") }, + text = { + Column { + Text("Should Gemma 3n E4B be downloaded? It requires 4.7 GB.") + Spacer(modifier = Modifier.height(8.dp)) + Text("Available storage: ${String.format("%.2f", availableGB)} GB", + color = if (availableGB < 5.0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface) + + if (isDownloading) { + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = downloadProgress, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "${(downloadProgress * 100).toInt()}%", + modifier = Modifier.align(Alignment.End), + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + if (!isDownloading) { + TextButton( + onClick = { + isDownloading = true + scope.launch { + val success = ModelDownloadManager.downloadModel(context) { progress -> + downloadProgress = progress + } + isDownloading = false + if (success) { + showDownloadDialog = false + Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Download failed", Toast.LENGTH_LONG).show() + } + } + } + ) { + Text("OK") + } + } + }, + dismissButton = { + if (!isDownloading) { + 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..e5ced87 100644 --- a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt @@ -35,6 +35,7 @@ import com.google.ai.client.generativeai.type.TextPart // For logging AI respons // Removed duplicate TextPart import import com.google.ai.sample.feature.multimodal.dtos.ContentDto import com.google.ai.sample.feature.multimodal.dtos.toSdk +import com.google.mediapipe.tasks.genai.llminference.LlmInference import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -90,7 +91,7 @@ class ScreenCaptureService : Service() { const val ACTION_AI_STREAM_UPDATE = "com.google.ai.sample.AI_STREAM_UPDATE" const val EXTRA_AI_STREAM_CHUNK = "com.google.ai.sample.EXTRA_AI_STREAM_CHUNK" - private var instance: ScreenCaptureService? = null + internal var instance: ScreenCaptureService? = null fun isRunning(): Boolean = instance != null && instance?.isReady == true } @@ -101,6 +102,7 @@ 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()) + internal var llmInference: LlmInference? = null // Callback for MediaProjection private val mediaProjectionCallback = object : MediaProjection.Callback() { @@ -265,7 +267,9 @@ class ScreenCaptureService : Service() { } } try { - if (apiProvider == ApiProvider.VERCEL) { + if (apiProvider == ApiProvider.OFFLINE_GEMMA) { + responseText = callOfflineGemma(applicationContext, chatHistory, inputContent) + } else if (apiProvider == ApiProvider.VERCEL) { val result = callVercelApi(modelName, apiKey, chatHistory, inputContent) responseText = result.first errorMessage = result.second @@ -674,6 +678,9 @@ class ScreenCaptureService : Service() { mediaProjection?.unregisterCallback(mediaProjectionCallback) mediaProjection?.stop() mediaProjection = null + + llmInference?.close() + llmInference = null } catch (e: Exception) { Log.e(TAG, "Error during full cleanup", e) } finally { @@ -749,6 +756,56 @@ private fun Bitmap.toBase64(): String { return "data:image/jpeg;base64," + android.util.Base64.encodeToString(outputStream.toByteArray(), android.util.Base64.DEFAULT) } +private fun callOfflineGemma(context: Context, chatHistory: List, inputContent: Content): String { + val modelFile = com.google.ai.sample.util.ModelDownloadManager.getModelFile(context) + if (!modelFile.exists()) { + return "Error: Offline model not found at ${modelFile.absolutePath}" + } + + // Initialize LlmInference if not already done + val llm = ScreenCaptureService.getLlmInferenceInstance(context) + + // Build the prompt from chat history and input content + val promptBuilder = StringBuilder() + + for (content in chatHistory) { + val role = if (content.role == "user") "user" else "model" + val text = content.parts.filterIsInstance().joinToString("\n") { it.text } + if (text.isNotBlank()) { + promptBuilder.append("$role\n$text\n") + } + } + + val inputText = inputContent.parts.filterIsInstance().joinToString("\n") { it.text } + promptBuilder.append("user\n$inputText\nmodel\n") + + val finalPrompt = promptBuilder.toString() + Log.d("ScreenCaptureService", "Offline Gemma Prompt: $finalPrompt") + + return try { + val response = llm.generateResponse(finalPrompt) + Log.d("ScreenCaptureService", "Offline Gemma Response: $response") + response + } catch (e: Exception) { + Log.e("ScreenCaptureService", "Error in offline Gemma inference", e) + "Error: ${e.localizedMessage}" + } +} + +// Added helper to get or create LlmInference instance +fun ScreenCaptureService.Companion.getLlmInferenceInstance(context: Context): LlmInference { + val service = instance ?: throw IllegalStateException("ScreenCaptureService not running") + if (service.llmInference == null) { + val modelFile = com.google.ai.sample.util.ModelDownloadManager.getModelFile(context) + val options = LlmInference.LlmInferenceOptions.builder() + .setModelPath(modelFile.absolutePath) + .setMaxTokens(1024) + .build() + service.llmInference = LlmInference.createFromOptions(context, options) + } + return service.llmInference!! +} + private suspend fun callVercelApi(modelName: String, apiKey: String, chatHistory: List, inputContent: Content): Pair { var responseText: String? = null var errorMessage: String? = null 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..ce8cf4e 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 @@ -90,7 +90,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import kotlinx.coroutines.delay import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection @@ -267,6 +273,22 @@ fun PhotoReasoningScreen( var entryToEdit: SystemMessageEntry? by rememberSaveable(stateSaver = SystemMessageEntrySaver) { mutableStateOf(null) } val listState = rememberLazyListState() val context = LocalContext.current + + val showScrollbar = remember { mutableStateOf(false) } + val scrollbarAlpha by animateFloatAsState( + targetValue = if (showScrollbar.value) 0.5f else 0f, + animationSpec = tween(durationMillis = 500), + label = "scrollbarAlpha" + ) + + LaunchedEffect(listState.isScrollInProgress) { + if (listState.isScrollInProgress) { + showScrollbar.value = true + } else { + delay(1000) + showScrollbar.value = false + } + } var systemMessageEntries by rememberSaveable { mutableStateOf(emptyList()) } val focusManager = LocalFocusManager.current val messages by chatMessages.collectAsState() @@ -408,7 +430,41 @@ fun PhotoReasoningScreen( } } - LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .drawWithContent { + drawContent() + val layoutInfo = listState.layoutInfo + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isNotEmpty() && layoutInfo.totalItemsCount > visibleItemsInfo.size) { + val totalItemsCount = layoutInfo.totalItemsCount + val firstVisibleItem = visibleItemsInfo.first() + val lastVisibleItem = visibleItemsInfo.last() + + val scrollbarWidth = 4.dp.toPx() + val viewPortHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + + // Estimate total height (heuristic) + val averageItemHeight = visibleItemsInfo.sumOf { it.size }.toFloat() / visibleItemsInfo.size + val totalHeight = averageItemHeight * totalItemsCount + + val scrollOffset = listState.firstVisibleItemIndex * averageItemHeight + listState.firstVisibleItemScrollOffset + + val scrollbarHeight = (viewPortHeight.toFloat() / totalHeight) * viewPortHeight + val scrollbarTop = (scrollOffset / totalHeight) * viewPortHeight + + drawRect( + color = Color.Gray, + topLeft = Offset(size.width - scrollbarWidth, scrollbarTop), + size = Size(scrollbarWidth, scrollbarHeight), + alpha = scrollbarAlpha + ) + } + } + ) { 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..13b0ab9 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/sample/util/ModelDownloadManager.kt @@ -0,0 +1,86 @@ +package com.google.ai.sample.util + +import android.content.Context +import android.os.Environment +import android.os.StatFs +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +object ModelDownloadManager { + private const val TAG = "ModelDownloadManager" + private const val MODEL_FILENAME = "gemma-3n-e4b-it-gpu.bin" + private const val DOWNLOAD_URL = "https://www.kaggle.com/api/v1/models/google/gemma-3/mediapipe/gemma-3-4b-it-gpu/1/download" + const val MODEL_SIZE_BYTES = 4_700_000_000L // 4.7 GB + + fun getModelFile(context: Context): File { + return File(context.getExternalFilesDir(null), MODEL_FILENAME) + } + + fun isModelDownloaded(context: Context): Boolean { + val file = getModelFile(context) + return file.exists() && file.length() >= MODEL_SIZE_BYTES * 0.9 // Basic check + } + + fun getAvailableInternalStorage(): Long { + val stat = StatFs(Environment.getDataDirectory().path) + return stat.availableBlocksLong * stat.blockSizeLong + } + + fun getAvailableExternalStorage(context: Context): Long { + val externalFilesDir = context.getExternalFilesDir(null) ?: return 0L + val stat = StatFs(externalFilesDir.path) + return stat.availableBlocksLong * stat.blockSizeLong + } + + suspend fun downloadModel(context: Context, onProgress: (Float) -> Unit): Boolean = withContext(Dispatchers.IO) { + val targetFile = getModelFile(context) + val tempFile = File(context.getExternalFilesDir(null), "$MODEL_FILENAME.tmp") + + try { + val url = URL(DOWNLOAD_URL) + val connection = url.openConnection() as HttpURLConnection + connection.connect() + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + Log.e(TAG, "Server returned HTTP ${connection.responseCode}") + return@withContext false + } + + val fileLength = connection.contentLengthLong + val input = connection.inputStream + val output = FileOutputStream(tempFile) + + val data = ByteArray(1024 * 64) + var total: Long = 0 + var count: Int + while (input.read(data).also { count = it } != -1) { + total += count + if (fileLength > 0) { + onProgress(total.toFloat() / fileLength) + } + output.write(data, 0, count) + } + + output.flush() + output.close() + input.close() + + if (tempFile.renameTo(targetFile)) { + Log.i(TAG, "Model downloaded successfully to ${targetFile.absolutePath}") + true + } else { + Log.e(TAG, "Failed to rename temp file to target file") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error downloading model", e) + if (tempFile.exists()) tempFile.delete() + false + } + } +} 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 }