From 05ad04deda464b6faef3870827e92ff4e6033539 Mon Sep 17 00:00:00 2001 From: Niral Patel Date: Tue, 1 Jul 2025 23:53:50 -0400 Subject: [PATCH] feat: Add Toggle Server for local inference This commit introduces a new "Toggle Server" feature that runs a local HTTP server on the device. This allows developers and researchers to interact with the on-device AI models using `curl`, with all communication tunneled exclusively over the USB cable. The server can handle multipart/form-data requests, allowing users to send a prompt, an image, or both. This provides a powerful new way to test, debug, and integrate the on-device models. --- .gitignore | 2 + Android/src/app/build.gradle.kts | 3 +- Android/src/app/src/main/AndroidManifest.xml | 2 + .../google/ai/edge/gallery/MainActivity.kt | 5 + .../com/google/ai/edge/gallery/data/Tasks.kt | 14 +- .../ai/edge/gallery/server/InAppServer.kt | 278 ++++++++++++++++++ .../gallery/ui/llmchat/LlmChatModelHelper.kt | 5 +- .../gallery/ui/llmchat/LlmChatViewModel.kt | 23 +- .../llmsingleturn/LlmSingleTurnViewModel.kt | 8 +- .../ui/modelmanager/ModelManagerViewModel.kt | 9 +- .../edge/gallery/ui/navigation/Destination.kt | 5 + .../gallery/ui/navigation/GalleryNavGraph.kt | 20 +- .../toggleserver/ToggleServerDestination.kt | 7 + .../ui/toggleserver/ToggleServerScreen.kt | 30 ++ .../ui/toggleserver/ToggleServerViewModel.kt | 33 +++ README.md | 28 ++ 16 files changed, 453 insertions(+), 19 deletions(-) create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/server/InAppServer.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/Destination.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerDestination.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerScreen.kt create mode 100644 Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerViewModel.kt diff --git a/.gitignore b/.gitignore index e43b0f9..34c0ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +.idea/ +.gemini/ diff --git a/Android/src/app/build.gradle.kts b/Android/src/app/build.gradle.kts index 70b62fb..4019098 100644 --- a/Android/src/app/build.gradle.kts +++ b/Android/src/app/build.gradle.kts @@ -99,6 +99,7 @@ dependencies { implementation(libs.hilt.navigation.compose) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) + implementation("commons-fileupload:commons-fileupload:1.4") kapt(libs.hilt.android.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) @@ -113,4 +114,4 @@ dependencies { protobuf { protoc { artifact = "com.google.protobuf:protoc:4.26.1" } generateProtoTasks { all().forEach { it.plugins { create("java") { option("lite") } } } } -} +} \ No newline at end of file diff --git a/Android/src/app/src/main/AndroidManifest.xml b/Android/src/app/src/main/AndroidManifest.xml index b4218ac..993cf37 100644 --- a/Android/src/app/src/main/AndroidManifest.xml +++ b/Android/src/app/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ + = Build.VERSION_CODES.M) { + requestPermissions(arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE), 1) + } } companion object { diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt index c52d2c3..6a3d220 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt @@ -33,6 +33,7 @@ enum class TaskType(val label: String, val id: String) { LLM_PROMPT_LAB(label = "Prompt Lab", id = "llm_prompt_lab"), LLM_ASK_IMAGE(label = "Ask Image", id = "llm_ask_image"), LLM_ASK_AUDIO(label = "Audio Scribe", id = "llm_ask_audio"), + TOGGLE_SERVER(label = "Toggle Server", id = "toggle_server"), TEST_TASK_1(label = "Test task 1", id = "test_task_1"), TEST_TASK_2(label = "Test task 2", id = "test_task_2"), } @@ -121,9 +122,20 @@ val TASK_LLM_ASK_AUDIO = textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, ) +val TASK_TOGGLE_SERVER = + Task( + type = TaskType.TOGGLE_SERVER, + icon = Icons.Outlined.Forum, + models = mutableListOf(), + description = "Toggle an LLM endpoint server running on-device (Placeholder).", + docUrl = "", + sourceCodeUrl = "", + textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat, + ) + /** All tasks. */ val TASKS: List = - listOf(TASK_LLM_ASK_IMAGE, TASK_LLM_ASK_AUDIO, TASK_LLM_PROMPT_LAB, TASK_LLM_CHAT) + listOf(TASK_LLM_ASK_IMAGE, TASK_LLM_ASK_AUDIO, TASK_LLM_PROMPT_LAB, TASK_LLM_CHAT, TASK_TOGGLE_SERVER) fun getModelByName(name: String): Model? { for (task in TASKS) { diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/server/InAppServer.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/server/InAppServer.kt new file mode 100644 index 0000000..e56f881 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/server/InAppServer.kt @@ -0,0 +1,278 @@ +package com.google.ai.edge.gallery.server + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE +import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.net.URLDecoder +import java.util.concurrent.CountDownLatch +import javax.inject.Inject +import javax.inject.Singleton +import org.apache.commons.fileupload.FileItem +import org.apache.commons.fileupload.disk.DiskFileItemFactory +import org.apache.commons.fileupload.servlet.ServletFileUpload + +@Singleton +class InAppServer @Inject constructor( + @ApplicationContext private val context: Context, + private val llmChatModelHelper: LlmChatModelHelper +) { + + private var serverSocket: ServerSocket? = null + private var serverThread: Thread? = null + @Volatile + private var isServerRunning = false + + fun start() { + if (isServerRunning) return + + serverThread = Thread { + try { + llmChatModelHelper.initialize(context, TASK_LLM_ASK_IMAGE.models.first()) { + if (it.isNotEmpty()) { + Log.e(TAG, "Failed to initialize model: $it") + return@initialize + } + } + + serverSocket = ServerSocket(DEVICE_PORT) + isServerRunning = true + Log.i(TAG, "In-App Server started on port " + DEVICE_PORT) + + while (isServerRunning) { + try { + val clientSocket = serverSocket!!.accept() + Log.i(TAG, "Client connected: " + clientSocket.inetAddress) + handleClient(clientSocket) + } catch (e: SocketException) { + if (!isServerRunning) { + Log.i(TAG, "Server socket closed intentionally.") + } else { + Log.e(TAG, "Error accepting connection", e) + } + } + } + } catch (e: IOException) { + Log.e(TAG, "Error starting server", e) + isServerRunning = false + } + } + serverThread!!.start() + } + + fun stop() { + if (!isServerRunning) return + + try { + isServerRunning = false + if (serverSocket != null && !serverSocket!!.isClosed) { + serverSocket!!.close() + } + if (serverThread != null) { + serverThread!!.interrupt() + serverThread = null + } + llmChatModelHelper.cleanUp(TASK_LLM_ASK_IMAGE.models.first()) + Log.i(TAG, "In-App Server stopped.") + } catch (e: IOException) { + Log.e(TAG, "Error stopping server", e) + } + } + + private fun handleClient(clientSocket: Socket) { + try { + val inputStream = clientSocket.inputStream + val writer = PrintWriter(clientSocket.outputStream, true) + + val requestLine = readLine(inputStream) + if (requestLine.isBlank()) { + clientSocket.close() + return + } + Log.i(TAG, "Request: $requestLine") + + val requestParts = requestLine.split(" ") + val method = requestParts[0] + + var contentType = "" + var contentLength = 0 + var line = readLine(inputStream) + while (line.isNotEmpty()) { + if (line.startsWith("Content-Type:", ignoreCase = true)) { + contentType = line.substringAfter(":").trim() + } else if (line.startsWith("Content-Length:", ignoreCase = true)) { + contentLength = line.substringAfter(":").trim().toInt() + } + line = readLine(inputStream) + } + + var prompt = "" + var imageData: ByteArray? = null + + if (method == "POST") { + if (contentLength > 0) { + val bodyBytes = ByteArray(contentLength) + var bytesRead = 0 + while (bytesRead < contentLength) { + val read = inputStream.read(bodyBytes, bytesRead, contentLength - bytesRead) + if (read == -1) break + bytesRead += read + } + + if (ServletFileUpload.isMultipartContent(RequestContext(ByteArrayInputStream(bodyBytes), contentType, contentLength))) { + val factory = DiskFileItemFactory() + val upload = ServletFileUpload(factory) + val items = upload.parseRequest(RequestContext(ByteArrayInputStream(bodyBytes), contentType, contentLength)) + for (item in items) { + if (item.isFormField) { + if (item.fieldName == "prompt") { + prompt = item.string + } + } else { + if (item.fieldName == "image") { + imageData = item.get() + } + } + } + } else { + prompt = String(bodyBytes) + } + } + } else { // GET + val queryParams = getQueryParams(requestLine) + prompt = queryParams["prompt"] ?: "" + } + + if (prompt.isBlank() && imageData == null) { + writer.println("HTTP/1.1 400 Bad Request") + writer.println("Content-Type: text/plain") + writer.println() + writer.println("No prompt or image provided.") + writer.flush() + clientSocket.close() + return + } + + writer.println("HTTP/1.1 200 OK") + writer.println("Content-Type: text/plain") + writer.println("Connection: close") + writer.println() + writer.flush() + + val latch = CountDownLatch(1) + llmChatModelHelper.resetSession(TASK_LLM_ASK_IMAGE.models.first()) + + val images: List = imageData?.let { + val bitmap = BitmapFactory.decodeByteArray(it, 0, it.size) + listOf(bitmap) + } ?: emptyList() + + llmChatModelHelper.runInference( + model = TASK_LLM_ASK_IMAGE.models.first(), + input = prompt, + images = images, + resultListener = { partialResult, done -> + writer.print(partialResult) + writer.flush() + if (done) { + clientSocket.close() + latch.countDown() + } + }, + cleanUpListener = { + if (!clientSocket.isClosed) { + writer.flush() + clientSocket.close() + } + latch.countDown() + } + ) + latch.await() + } catch (e: Exception) { + Log.e(TAG, "Error handling client", e) + } finally { + try { + if (!clientSocket.isClosed) { + clientSocket.close() + } + } catch (e: IOException) { + Log.e(TAG, "Error closing client socket", e) + } + } + } + + private fun readLine(stream: InputStream): String { + val buffer = ByteArrayOutputStream() + while (true) { + val b = stream.read() + if (b == -1) break + if (b == '\n'.code) { + break + } + buffer.write(b) + } + val bytes = buffer.toByteArray() + if (bytes.isNotEmpty() && bytes.last() == '\r'.toByte()) { + return String(bytes, 0, bytes.size - 1, Charsets.ISO_8859_1) + } + return String(bytes, Charsets.ISO_8859_1) + } + + private fun getQueryParams(requestLine: String): Map { + val queryParams = mutableMapOf() + val urlParts = requestLine.split(" ")[1].split("?") + if (urlParts.size > 1) { + val query = urlParts[1] + for (param in query.split("&")) { + val pair = param.split("=") + if (pair.size > 1) { + queryParams[URLDecoder.decode(pair[0], "UTF-8")] = + URLDecoder.decode(pair[1], "UTF-8") + } + } + } + return queryParams + } + + fun isRunning(): Boolean { + return isServerRunning + } + + companion object { + private const val TAG = "AIEdgeServer" + private const val DEVICE_PORT = 8080 + } +} + +class RequestContext( + private val inputStream: java.io.InputStream, + private val contentType: String, + private val contentLength: Int +) : org.apache.commons.fileupload.RequestContext { + override fun getCharacterEncoding(): String { + return "UTF-8" + } + + override fun getContentType(): String { + return contentType + } + + override fun getContentLength(): Int { + return contentLength + } + + override fun getInputStream(): java.io.InputStream { + return inputStream + } +} \ No newline at end of file diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt index a330c35..5ab062c 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt @@ -32,6 +32,8 @@ import com.google.mediapipe.framework.image.BitmapImageBuilder import com.google.mediapipe.tasks.genai.llminference.GraphOptions import com.google.mediapipe.tasks.genai.llminference.LlmInference import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession +import javax.inject.Inject +import javax.inject.Singleton private const val TAG = "AGLlmChatModelHelper" @@ -41,7 +43,8 @@ typealias CleanUpListener = () -> Unit data class LlmModelInstance(val engine: LlmInference, var session: LlmInferenceSession) -object LlmChatModelHelper { +@Singleton +class LlmChatModelHelper @Inject constructor() { // Indexed by model name. private val cleanUpListeners: MutableMap = mutableMapOf() 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 205d4e0..a51011e 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 @@ -51,7 +51,10 @@ private val STATS = Stat(id = "latency", label = "Latency", unit = "sec"), ) -open class LlmChatViewModelBase(val curTask: Task) : ChatViewModel(task = curTask) { +open class LlmChatViewModelBase( + val curTask: Task, + private val llmChatModelHelper: LlmChatModelHelper +) : ChatViewModel(task = curTask) { fun generateResponse( model: Model, input: String, @@ -92,7 +95,7 @@ open class LlmChatViewModelBase(val curTask: Task) : ChatViewModel(task = curTas val start = System.currentTimeMillis() try { - LlmChatModelHelper.runInference( + llmChatModelHelper.runInference( model = model, input = input, images = images, @@ -195,7 +198,7 @@ open class LlmChatViewModelBase(val curTask: Task) : ChatViewModel(task = curTas while (true) { try { - LlmChatModelHelper.resetSession(model = model) + llmChatModelHelper.resetSession(model = model) break } catch (e: Exception) { Log.d(TAG, "Failed to reset session. Trying again") @@ -262,12 +265,16 @@ open class LlmChatViewModelBase(val curTask: Task) : ChatViewModel(task = curTas } @HiltViewModel -class LlmChatViewModel @Inject constructor() : LlmChatViewModelBase(curTask = TASK_LLM_CHAT) +class LlmChatViewModel @Inject constructor( + llmChatModelHelper: LlmChatModelHelper +) : LlmChatViewModelBase(curTask = TASK_LLM_CHAT, llmChatModelHelper = llmChatModelHelper) @HiltViewModel -class LlmAskImageViewModel @Inject constructor() : - LlmChatViewModelBase(curTask = TASK_LLM_ASK_IMAGE) +class LlmAskImageViewModel @Inject constructor( + llmChatModelHelper: LlmChatModelHelper +) : LlmChatViewModelBase(curTask = TASK_LLM_ASK_IMAGE, llmChatModelHelper = llmChatModelHelper) @HiltViewModel -class LlmAskAudioViewModel @Inject constructor() : - LlmChatViewModelBase(curTask = TASK_LLM_ASK_AUDIO) +class LlmAskAudioViewModel @Inject constructor( + llmChatModelHelper: LlmChatModelHelper +) : LlmChatViewModelBase(curTask = TASK_LLM_ASK_AUDIO, llmChatModelHelper = llmChatModelHelper) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt index 861d5fd..b0debea 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmsingleturn/LlmSingleTurnViewModel.kt @@ -66,7 +66,9 @@ private val STATS = ) @HiltViewModel -class LlmSingleTurnViewModel @Inject constructor() : ViewModel() { +class LlmSingleTurnViewModel @Inject constructor( + private val llmChatModelHelper: LlmChatModelHelper +) : ViewModel() { private val _uiState = MutableStateFlow(createUiState(task = TASK_LLM_PROMPT_LAB)) val uiState = _uiState.asStateFlow() @@ -80,7 +82,7 @@ class LlmSingleTurnViewModel @Inject constructor() : ViewModel() { delay(100) } - LlmChatModelHelper.resetSession(model = model) + llmChatModelHelper.resetSession(model = model) delay(500) // Run inference. @@ -96,7 +98,7 @@ class LlmSingleTurnViewModel @Inject constructor() : ViewModel() { val start = System.currentTimeMillis() var response = "" var lastBenchmarkUpdateTs = 0L - LlmChatModelHelper.runInference( + llmChatModelHelper.runInference( model = model, input = input, resultListener = { partialResult, done -> 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 b8b4eee..8bf1ece 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 @@ -144,6 +144,7 @@ constructor( private val downloadRepository: DownloadRepository, private val dataStoreRepository: DataStoreRepository, private val lifecycleProvider: AppLifecycleProvider, + private val llmChatModelHelper: LlmChatModelHelper, @ApplicationContext private val context: Context, ) : ViewModel() { private val externalFilesDir = context.getExternalFilesDir(null) @@ -292,8 +293,8 @@ constructor( TaskType.LLM_ASK_IMAGE, TaskType.LLM_ASK_AUDIO, TaskType.LLM_PROMPT_LAB -> - LlmChatModelHelper.initialize(context = context, model = model, onDone = onDone) - + llmChatModelHelper.initialize(context = context, model = model, onDone = onDone) + TaskType.TOGGLE_SERVER -> {} TaskType.TEST_TASK_1 -> {} TaskType.TEST_TASK_2 -> {} } @@ -308,8 +309,8 @@ constructor( TaskType.LLM_CHAT, TaskType.LLM_PROMPT_LAB, TaskType.LLM_ASK_IMAGE, - TaskType.LLM_ASK_AUDIO -> LlmChatModelHelper.cleanUp(model = model) - + TaskType.LLM_ASK_AUDIO -> llmChatModelHelper.cleanUp(model = model) + TaskType.TOGGLE_SERVER -> {} TaskType.TEST_TASK_1 -> {} TaskType.TEST_TASK_2 -> {} } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/Destination.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/Destination.kt new file mode 100644 index 0000000..2617314 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/Destination.kt @@ -0,0 +1,5 @@ +package com.google.ai.edge.gallery.ui.navigation + +interface Destination { + val route: String +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt index e4ebd48..f2bf180 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/navigation/GalleryNavGraph.kt @@ -69,6 +69,8 @@ import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnScreen import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnViewModel import com.google.ai.edge.gallery.ui.modelmanager.ModelManager import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel +import com.google.ai.edge.gallery.ui.toggleserver.ToggleServerDestination +import com.google.ai.edge.gallery.ui.toggleserver.ToggleServerScreen private const val TAG = "AGGalleryNavGraph" private const val ROUTE_PLACEHOLDER = "placeholder" @@ -143,7 +145,14 @@ fun GalleryNavHost( modelManagerViewModel = modelManagerViewModel, navigateToTaskScreen = { task -> pickedTask = task - showModelManager = true + if (task.type == TaskType.TOGGLE_SERVER) { + navigateToTaskScreen( + navController = navController, + taskType = task.type, + ) + } else { + showModelManager = true + } }, ) @@ -260,6 +269,14 @@ fun GalleryNavHost( ) } } + + composable( + route = ToggleServerDestination.route, + enterTransition = { slideEnter() }, + exitTransition = { slideExit() }, + ) { + ToggleServerScreen() + } } // Handle incoming intents for deep links @@ -294,6 +311,7 @@ fun navigateToTaskScreen( TaskType.LLM_ASK_AUDIO -> navController.navigate("${LlmAskAudioDestination.route}/${modelName}") TaskType.LLM_PROMPT_LAB -> navController.navigate("${LlmSingleTurnDestination.route}/${modelName}") + TaskType.TOGGLE_SERVER -> navController.navigate(ToggleServerDestination.route) TaskType.TEST_TASK_1 -> {} TaskType.TEST_TASK_2 -> {} } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerDestination.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerDestination.kt new file mode 100644 index 0000000..6e4bedf --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerDestination.kt @@ -0,0 +1,7 @@ +package com.google.ai.edge.gallery.ui.toggleserver + +import com.google.ai.edge.gallery.ui.navigation.Destination + +object ToggleServerDestination : Destination { + override val route = "toggle_server" +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerScreen.kt new file mode 100644 index 0000000..70fdeb2 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerScreen.kt @@ -0,0 +1,30 @@ +package com.google.ai.edge.gallery.ui.toggleserver + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel + +@Composable +fun ToggleServerScreen( + toggleServerViewModel: ToggleServerViewModel = hiltViewModel() +) { + val isServerRunning by toggleServerViewModel.isServerRunning.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button(onClick = { toggleServerViewModel.toggleServer() }) { + Text(if (isServerRunning) "Stop In-App Server" else "Start In-App Server") + } + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerViewModel.kt new file mode 100644 index 0000000..aa3f108 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/toggleserver/ToggleServerViewModel.kt @@ -0,0 +1,33 @@ +package com.google.ai.edge.gallery.ui.toggleserver + +import androidx.lifecycle.ViewModel +import com.google.ai.edge.gallery.server.InAppServer +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import android.util.Log + +@HiltViewModel +class ToggleServerViewModel @Inject constructor( + private val inAppServer: InAppServer +) : ViewModel() { + + private val _isServerRunning = MutableStateFlow(inAppServer.isRunning()) + val isServerRunning: StateFlow = _isServerRunning + + fun toggleServer() { + Log.d("ToggleServerViewModel", "toggleServer called") + if (inAppServer.isRunning()) { + inAppServer.stop() + } else { + inAppServer.start() + } + _isServerRunning.value = inAppServer.isRunning() + } + + override fun onCleared() { + super.onCleared() + inAppServer.stop() + } +} diff --git a/README.md b/README.md index 2c58b3f..308d379 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,34 @@ The Google AI Edge Gallery is an experimental app that puts the power of cutting **AI Chat** AI Chat +## 🔌 Toggle Server + +The "Toggle Server" feature runs a local HTTP server on your mobile device that allows you to interact with the on-device AI models from your laptop using `curl`, with all communication tunneled exclusively over a USB cable connection. + +### Usage + +1. **Enable USB Debugging**: + * Follow these [steps](https://developer.android.com/studio/debug/dev-options) to enable ADB port forwarding between your device and computer. + +2. **Connect Device to Computer & Enable Port Forwarding**: + ```bash + adb -d forward tcp:8080 tcp:8080 + ``` + +3. **Start the Server in the App**: + * Navigate to the "Toggle Server" screen. + * Tap the "Start In-App Server" button. + +4. **Send Requests with `curl`**: + * **Prompt only**: + ```bash + curl -X POST -F "prompt=Hello, world!" http://localhost:8080 + ``` + * **Image and prompt**: + ```bash + curl -X POST -F "prompt=What is in this image?" -F "image=@/path/to/your/image.jpg" http://localhost:8080 + ``` + ## ✨ Core Features * **📱 Run Locally, Fully Offline:** Experience the magic of GenAI without an internet connection. All processing happens directly on your device.