diff --git a/Android/src/app/build.gradle.kts b/Android/src/app/build.gradle.kts index 1e42bd6..9d63325 100644 --- a/Android/src/app/build.gradle.kts +++ b/Android/src/app/build.gradle.kts @@ -31,7 +31,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "1.0.1" + versionName = "1.0.2" // Needed for HuggingFace auth workflows. manifestPlaceholders["appAuthRedirectScheme"] = "com.google.ai.edge.gallery.oauth" diff --git a/Android/src/app/src/main/AndroidManifest.xml b/Android/src/app/src/main/AndroidManifest.xml index acd3cb2..3d71053 100644 --- a/Android/src/app/src/main/AndroidManifest.xml +++ b/Android/src/app/src/main/AndroidManifest.xml @@ -18,8 +18,8 @@ - - + + @@ -83,11 +83,12 @@ android:resource="@xml/file_paths" /> - - - - - + + \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt index 57a3282..4f6bb97 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Consts.kt @@ -18,6 +18,7 @@ package com.google.ai.edge.gallery.data // Keys used to send/receive data to Work. const val KEY_MODEL_URL = "KEY_MODEL_URL" +const val KEY_MODEL_NAME = "KEY_MODEL_NAME" const val KEY_MODEL_VERSION = "KEY_MODEL_VERSION" const val KEY_MODEL_DOWNLOAD_MODEL_DIR = "KEY_MODEL_DOWNLOAD_MODEL_DIR" const val KEY_MODEL_DOWNLOAD_FILE_NAME = "KEY_MODEL_DOWNLOAD_FILE_NAME" diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt index 8dcd804..065c472 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/DownloadRepository.kt @@ -24,7 +24,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat @@ -92,7 +91,8 @@ class DefaultDownloadRepository( val builder = Data.Builder() val totalBytes = model.totalBytes + model.extraDataFiles.sumOf { it.sizeInBytes } val inputDataBuilder = - builder.putString(KEY_MODEL_URL, model.url).putString(KEY_MODEL_VERSION, model.version) + builder.putString(KEY_MODEL_NAME, model.name).putString(KEY_MODEL_URL, model.url) + .putString(KEY_MODEL_VERSION, model.version) .putString(KEY_MODEL_DOWNLOAD_MODEL_DIR, model.normalizedName) .putString(KEY_MODEL_DOWNLOAD_FILE_NAME, model.downloadFileName) .putBoolean(KEY_MODEL_IS_ZIP, model.isZip).putString(KEY_MODEL_UNZIPPED_DIR, model.unzipDir) @@ -271,13 +271,11 @@ class DefaultDownloadRepository( // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(channelId, channelName, importance) - val notificationManager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(channelId, channelName, importance) + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) // Create an Intent to open your app with a deep link. val intent = Intent( diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt index c33c462..50cae09 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatMessage.kt @@ -44,7 +44,10 @@ data class Classification(val label: String, val score: Float, val color: Color) /** Base class for a chat message. */ open class ChatMessage( - open val type: ChatMessageType, open val side: ChatSide, open val latencyMs: Float = -1f + open val type: ChatMessageType, + open val side: ChatSide, + open val latencyMs: Float = -1f, + open val accelerator: String = "", ) { open fun clone(): ChatMessage { return ChatMessage(type = type, side = side, latencyMs = latencyMs) @@ -52,7 +55,8 @@ open class ChatMessage( } /** Chat message for showing loading status. */ -class ChatMessageLoading : ChatMessage(type = ChatMessageType.LOADING, side = ChatSide.AGENT) +class ChatMessageLoading(override val accelerator: String = "") : + ChatMessage(type = ChatMessageType.LOADING, side = ChatSide.AGENT, accelerator = accelerator) /** Chat message for info (help). */ class ChatMessageInfo(val content: String) : @@ -79,12 +83,19 @@ open class ChatMessageText( // Benchmark result for LLM response. var llmBenchmarkResult: ChatMessageBenchmarkLlmResult? = null, -) : ChatMessage(type = ChatMessageType.TEXT, side = side, latencyMs = latencyMs) { + override val accelerator: String = "", +) : ChatMessage( + type = ChatMessageType.TEXT, + side = side, + latencyMs = latencyMs, + accelerator = accelerator +) { override fun clone(): ChatMessageText { return ChatMessageText( content = content, side = side, latencyMs = latencyMs, + accelerator = accelerator, isMarkdown = isMarkdown, llmBenchmarkResult = llmBenchmarkResult, ) @@ -168,10 +179,12 @@ class ChatMessageBenchmarkLlmResult( val statValues: MutableMap, val running: Boolean, override val latencyMs: Float = 0f, + override val accelerator: String = "", ) : ChatMessage( type = ChatMessageType.BENCHMARK_LLM_RESULT, side = ChatSide.AGENT, - latencyMs = latencyMs + latencyMs = latencyMs, + accelerator = accelerator, ) data class Histogram( diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt index 85a9df1..1e035eb 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt @@ -80,7 +80,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R -import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.TaskType @@ -266,9 +265,13 @@ fun ChatPanel( horizontalAlignment = hAlign, ) messageColumn@{ // Sender row. + var agentName = stringResource(task.agentNameRes) + if (message.accelerator.isNotEmpty()) { + agentName = "$agentName on ${message.accelerator}" + } MessageSender( message = message, - agentName = stringResource(task.agentNameRes), + agentName = agentName, imageHistoryCurIndex = imageHistoryCurIndex.intValue ) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt index 285506c..7efdeef 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatViewModel.kt @@ -141,6 +141,7 @@ open class ChatViewModel(val task: Task) : ViewModel() { content = newContent, side = lastMessage.side, latencyMs = latencyMs, + accelerator = lastMessage.accelerator, ) newMessages.removeAt(newMessages.size - 1) newMessages.add(newLastMessage) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt index 45b0940..73b1713 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageInputText.kt @@ -23,7 +23,6 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.net.Uri -import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -31,9 +30,9 @@ import androidx.annotation.StringRes import androidx.camera.core.CameraControl import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageProxy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance -import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -57,12 +56,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FlipCameraAndroid import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Photo import androidx.compose.material.icons.rounded.PhotoCamera import androidx.compose.material.icons.rounded.PostAdd import androidx.compose.material.icons.rounded.Stop -import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -81,13 +80,13 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.focusModifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext @@ -96,13 +95,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.ui.common.createTempPictureUri import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel import com.google.ai.edge.gallery.ui.theme.GalleryTheme +import kotlinx.coroutines.launch import java.util.concurrent.Executors /** @@ -131,6 +130,7 @@ fun MessageInputText( showStopButtonWhenInProgress: Boolean = false, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() var showAddContentMenu by remember { mutableStateOf(false) } var showTextInputHistorySheet by remember { mutableStateOf(false) } @@ -144,6 +144,11 @@ fun MessageInputText( newPickedImages.add(bitmap) pickedImages = newPickedImages.toList() } + var hasFrontCamera by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + checkFrontCamera(context = context, callback = { hasFrontCamera = it }) + } // launches camera val cameraLauncher = @@ -167,8 +172,8 @@ fun MessageInputText( if (permissionGranted) { showAddContentMenu = false tempPhotoUri = context.createTempPictureUri() -// showCameraCaptureBottomSheet = true - cameraLauncher.launch(tempPhotoUri) + showCameraCaptureBottomSheet = true +// cameraLauncher.launch(tempPhotoUri) } } @@ -264,8 +269,8 @@ fun MessageInputText( ) -> { showAddContentMenu = false tempPhotoUri = context.createTempPictureUri() -// showCameraCaptureBottomSheet = true - cameraLauncher.launch(tempPhotoUri) + showCameraCaptureBottomSheet = true +// cameraLauncher.launch(tempPhotoUri) } // Otherwise, ask for permission @@ -408,12 +413,14 @@ fun MessageInputText( ModalBottomSheet( sheetState = cameraCaptureSheetState, onDismissRequest = { showCameraCaptureBottomSheet = false }) { + val lifecycleOwner = LocalLifecycleOwner.current val previewUseCase = remember { androidx.camera.core.Preview.Builder().build() } val imageCaptureUseCase = remember { ImageCapture.Builder().build() } var cameraProvider by remember { mutableStateOf(null) } var cameraControl by remember { mutableStateOf(null) } val localContext = LocalContext.current + var cameraSide by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } val executor = remember { Executors.newSingleThreadExecutor() } val capturedImageUri = remember { mutableStateOf(null) } @@ -421,7 +428,7 @@ fun MessageInputText( fun rebindCameraProvider() { cameraProvider?.let { cameraProvider -> val cameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_FRONT) + .requireLensFacing(cameraSide) .build() cameraProvider.unbindAll() val camera = cameraProvider.bindToLifecycle( @@ -439,6 +446,10 @@ fun MessageInputText( rebindCameraProvider() } + LaunchedEffect(cameraSide) { + rebindCameraProvider() + } + // val cameraController = remember { // LifecycleCameraController(context).apply { // bindToLifecycle(lifecycleOwner) @@ -465,25 +476,92 @@ fun MessageInputText( // cameraController.unbind() // Unbinds the camera to free up resources // } ) + + // Close button. + IconButton( + onClick = { + scope.launch { + cameraCaptureSheetState.hide() + showCameraCaptureBottomSheet = false + } + }, colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), modifier = Modifier + .offset(x = (-8).dp, y = 8.dp) + .align(Alignment.TopEnd) + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "", + tint = MaterialTheme.colorScheme.primary + ) + } + // Button that triggers the image capture process IconButton( colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, + containerColor = MaterialTheme.colorScheme.primary, ), modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 32.dp) - .size(64.dp), + .size(64.dp) + .border(2.dp, MaterialTheme.colorScheme.onPrimary, CircleShape), onClick = { + scope.launch { + val callback = object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + var bitmap = image.toBitmap() + val rotation = image.imageInfo.rotationDegrees + bitmap = if (rotation != 0) { + val matrix = Matrix().apply { + postRotate(rotation.toFloat()) + } + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } else bitmap + updatePickedImages(bitmap) + image.close() + } + } + imageCaptureUseCase.takePicture(executor, callback) + cameraCaptureSheetState.hide() + showCameraCaptureBottomSheet = false + } }, ) { Icon( Icons.Rounded.PhotoCamera, contentDescription = "", - tint = MaterialTheme.colorScheme.onTertiaryContainer, + tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(36.dp) ) } + + // Button that toggles the front and back camera. + if (hasFrontCamera) { + IconButton( + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 40.dp, end = 32.dp) + .size(48.dp), + onClick = { + cameraSide = when (cameraSide) { + CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT + else -> CameraSelector.LENS_FACING_BACK + } + }, + ) { + Icon( + Icons.Rounded.FlipCameraAndroid, + contentDescription = "", + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp) + ) + } + } } } } @@ -501,13 +579,7 @@ private fun handleImageSelected( val bitmap: Bitmap? = try { val inputStream = context.contentResolver.openInputStream(uri) val tmpBitmap = BitmapFactory.decodeStream(inputStream) - if (rotateForPortrait && tmpBitmap.width > tmpBitmap.height) { - val matrix = Matrix() - matrix.postRotate(90f) - Bitmap.createBitmap(tmpBitmap, 0, 0, tmpBitmap.width, tmpBitmap.height, matrix, true) - } else { - tmpBitmap - } + rotateImageIfNecessary(bitmap = tmpBitmap, rotateForPortrait = rotateForPortrait) } catch (e: Exception) { e.printStackTrace() null @@ -517,6 +589,31 @@ private fun handleImageSelected( } } +private fun rotateImageIfNecessary(bitmap: Bitmap, rotateForPortrait: Boolean = false): Bitmap { + return if (rotateForPortrait && bitmap.width > bitmap.height) { + val matrix = Matrix() + matrix.postRotate(90f) + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } else { + bitmap + } +} + +private fun checkFrontCamera(context: Context, callback: (Boolean) -> Unit) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener(Runnable { + val cameraProvider = cameraProviderFuture.get() + try { + // Attempt to select the default front camera + val hasFront = cameraProvider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) + callback(hasFront) + } catch (e: Exception) { + e.printStackTrace() + callback(false) + } + }, ContextCompat.getMainExecutor(context)) +} + private fun createMessagesToSend(pickedImages: List, text: String): List { val messages: MutableList = mutableListOf() if (pickedImages.isNotEmpty()) { diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt index 38d6860..ac1375c 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/MessageSender.kt @@ -180,6 +180,9 @@ private fun getMessageLayoutConfig( horizontalArrangement = Arrangement.SpaceBetween modifier = modifier.fillMaxWidth() userLabel = "Stats" + if (message.accelerator.isNotEmpty()) { + userLabel = "${userLabel} on ${message.accelerator}" + } } is ChatMessageImageWithHistory -> { diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt index 37e48c3..5b124c1 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/home/HomeScreen.kt @@ -388,7 +388,7 @@ private fun TaskList( introText, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 20.dp) + modifier = Modifier.padding(bottom = 20.dp).padding(horizontal = 16.dp) ) } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt index 9eff00a..b99cdc8 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatViewModel.kt @@ -20,6 +20,7 @@ import android.content.Context import android.graphics.Bitmap import android.util.Log import androidx.lifecycle.viewModelScope +import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.TASK_LLM_CHAT import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE @@ -47,6 +48,7 @@ private val STATS = listOf( open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task = curTask) { fun generateResponse(model: Model, input: String, image: Bitmap? = null, onError: () -> Unit) { + val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "") viewModelScope.launch(Dispatchers.Default) { setInProgress(true) setPreparing(true) @@ -54,7 +56,7 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task // Loading. addMessage( model = model, - message = ChatMessageLoading(), + message = ChatMessageLoading(accelerator = accelerator), ) // Wait for instance to be initialized. @@ -103,7 +105,12 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task // Add an empty message that will receive streaming results. addMessage( - model = model, message = ChatMessageText(content = "", side = ChatSide.AGENT) + model = model, + message = ChatMessageText( + content = "", + side = ChatSide.AGENT, + accelerator = accelerator + ) ) } @@ -133,6 +140,7 @@ open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task ), running = false, latencyMs = -1f, + accelerator = accelerator, ) ) } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt index 43ece19..28116a9 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/ResponsePanel.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoAwesome @@ -56,6 +58,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.ai.edge.gallery.data.ConfigKey import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB import com.google.ai.edge.gallery.ui.common.chat.MarkdownText @@ -88,6 +92,7 @@ fun ResponsePanel( val pagerState = rememberPagerState( initialPage = task.models.indexOf(model), pageCount = { task.models.size }) + val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "") // Select the "response" tab when prompt template changes. LaunchedEffect(selectedPromptTemplateType) { @@ -191,7 +196,22 @@ fun ResponsePanel( .size(16.dp) .alpha(0.7f) ) - Text(text = title) + var curTitle = title + if (accelerator.isNotEmpty()) { + curTitle = "$curTitle on $accelerator" + } + val titleColor = MaterialTheme.colorScheme.primary + BasicText( + text = curTitle, + maxLines = 1, + color = { titleColor }, + style = MaterialTheme.typography.bodyMedium, + autoSize = TextAutoSize.StepBased( + minFontSize = 9.sp, + maxFontSize = 14.sp, + stepSize = 1.sp + ) + ) } }) } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt index ee8a295..806a6e6 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelList.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Description @@ -91,11 +92,14 @@ fun ModelList( } } + val listState = rememberLazyListState() + Box(contentAlignment = Alignment.BottomEnd) { LazyColumn( modifier = modifier.padding(top = 8.dp), contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(8.dp), + state = listState, ) { // Headline. item(key = "headline") { @@ -103,7 +107,9 @@ fun ModelList( task.description, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt index 2abad3c..63b5db7 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/modelmanager/ModelManagerViewModel.kt @@ -382,6 +382,8 @@ open class ModelManagerViewModel( } _uiState.update { _uiState.value.copy(textInputHistory = newHistory) } dataStoreRepository.saveTextInputHistory(_uiState.value.textInputHistory) + } else { + promoteTextInputHistoryItem(text) } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt index c294535..ed1a2d2 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/worker/DownloadWorker.kt @@ -16,10 +16,16 @@ package com.google.ai.edge.gallery.worker +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build import android.util.Log +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_ACCESS_TOKEN import com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_APP_TS @@ -32,6 +38,7 @@ import com.google.ai.edge.gallery.data.KEY_MODEL_DOWNLOAD_REMAINING_MS import com.google.ai.edge.gallery.data.KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES import com.google.ai.edge.gallery.data.KEY_MODEL_EXTRA_DATA_URLS import com.google.ai.edge.gallery.data.KEY_MODEL_IS_ZIP +import com.google.ai.edge.gallery.data.KEY_MODEL_NAME import com.google.ai.edge.gallery.data.KEY_MODEL_START_UNZIPPING import com.google.ai.edge.gallery.data.KEY_MODEL_TOTAL_BYTES import com.google.ai.edge.gallery.data.KEY_MODEL_UNZIPPED_DIR @@ -57,14 +64,40 @@ data class UrlAndFileName( val fileName: String, ) +private const val FOREGROUND_NOTIFICATION_CHANNEL_ID = "model_download_channel_foreground" +private var channelCreated = false + class DownloadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { private val externalFilesDir = context.getExternalFilesDir(null) + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Unique notification id. + private val notificationId: Int = params.id.hashCode() + + init { + if (!channelCreated) { + // Create a notification channel for showing notifications for model downloading progress. + val channel = NotificationChannel( + FOREGROUND_NOTIFICATION_CHANNEL_ID, + "Model Downloading", + // Make it silent. + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Notifications for model downloading" + } + notificationManager.createNotificationChannel(channel) + channelCreated = true + } + } + override suspend fun doWork(): Result { val appTs = readLaunchInfo(context = applicationContext)?.ts ?: 0 val fileUrl = inputData.getString(KEY_MODEL_URL) + val modelName = inputData.getString(KEY_MODEL_NAME) ?: "Model" val version = inputData.getString(KEY_MODEL_VERSION)!! val fileName = inputData.getString(KEY_MODEL_DOWNLOAD_FILE_NAME) val modelDir = inputData.getString(KEY_MODEL_DOWNLOAD_MODEL_DIR)!! @@ -87,6 +120,9 @@ class DownloadWorker(context: Context, params: WorkerParameters) : Result.failure() } else { return@withContext try { + // Set the worker as a foreground service immediately. + setForeground(createForegroundInfo(progress = 0, modelName = modelName)) + // Collect data for all files. val allFiles: MutableList = mutableListOf() allFiles.add(UrlAndFileName(url = fileUrl, fileName = fileName)) @@ -206,6 +242,11 @@ class DownloadWorker(context: Context, params: WorkerParameters) : KEY_MODEL_DOWNLOAD_REMAINING_MS, remainingMs.toLong() ).build() ) + setForeground( + createForegroundInfo( + progress = (downloadedBytes * 100 / totalBytes).toInt(), modelName = modelName + ) + ) Log.d(TAG, "downloadedBytes: $downloadedBytes") lastSetProgressTs = curTs } @@ -221,11 +262,10 @@ class DownloadWorker(context: Context, params: WorkerParameters) : setProgress(Data.Builder().putBoolean(KEY_MODEL_START_UNZIPPING, true).build()) // Prepare target dir. - val destDir = - File( - externalFilesDir, - listOf(modelDir, version, unzippedDir).joinToString(File.separator) - ) + val destDir = File( + externalFilesDir, + listOf(modelDir, version, unzippedDir).joinToString(File.separator) + ) if (!destDir.exists()) { destDir.mkdirs() } @@ -276,4 +316,42 @@ class DownloadWorker(context: Context, params: WorkerParameters) : } } } + + override suspend fun getForegroundInfo(): ForegroundInfo { + // Initial progress is 0 + return createForegroundInfo(0) + } + + /** + * Creates a [ForegroundInfo] object for the download worker's ongoing notification. + * This notification is used to keep the worker running in the foreground, indicating + * to the user that an active download is in progress. + */ + private fun createForegroundInfo(progress: Int, modelName: String? = null): ForegroundInfo { + // Create a notification for the foreground service + var title = "Downloading model" + if (modelName != null) { + title = "Downloading \"$modelName\"" + } + val content = "Downloading in progress: $progress%" + + val notification = + NotificationCompat.Builder(applicationContext, FOREGROUND_NOTIFICATION_CHANNEL_ID) + .setContentTitle(title).setContentText(content) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) // Makes the notification non-dismissable + .setProgress(100, progress, false) // Show progress + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo( + notificationId, + notification, + ) + } + } } \ No newline at end of file diff --git a/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6942fb7..345888d 100644 --- a/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/Android/src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,20 +1,4 @@ - - diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png index 8f6b3fb..44f0938 100644 Binary files a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png index 13f178c..1966948 100644 Binary files a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index ec172b7..43b53a3 100644 Binary files a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png index ec172b7..43b53a3 100644 Binary files a/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/Android/src/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png index b860434..4d0086b 100644 Binary files a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png index 46e114d..75025cf 100644 Binary files a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 8b41ec7..26e24ba 100644 Binary files a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png index 8b41ec7..26e24ba 100644 Binary files a/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/Android/src/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 9981e9a..b9fe1e6 100644 Binary files a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png index bf6bc62..9784f16 100644 Binary files a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 5606e5f..b360465 100644 Binary files a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png index 5606e5f..b360465 100644 Binary files a/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/Android/src/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d16a726..7a25194 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png index be8ef1d..04ef206 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 760ef15..e0ea6e8 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png index 760ef15..e0ea6e8 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/Android/src/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 92a5411..28c9f3d 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png index e165902..66a5487 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 0a45db8..9ed6922 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png index 0a45db8..9ed6922 100644 Binary files a/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/Android/src/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ