Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Android/src/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ dependencies {
implementation(libs.mcp.kotlin.sdk)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.core)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.serialization.kotlinx.json)
}

protobuf {
Expand Down
15 changes: 15 additions & 0 deletions Android/src/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
Expand All @@ -58,6 +63,7 @@
<application
android:name="${applicationName}"
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="${appIcon}"
Expand Down Expand Up @@ -126,6 +132,15 @@
tools:node="merge">
</service>

<service
android:name=".server.OpenAiApiServerService"
android:exported="false"
android:foregroundServiceType="specialUse|connectedDevice">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Hosts a user-enabled OpenAI-compatible API on the local network" />
</service>

<!-- For Firebase Analytics. -->
<receiver
android:name="com.google.android.gms.measurement.AppMeasurementReceiver"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ import com.google.ai.edge.gallery.proto.UserData
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

data class OpenAiApiServerPreferences(
val enabled: Boolean = false,
val port: Int = 8080,
val modelName: String = "",
)

data class FrpPreferences(
val enabled: Boolean = false,
val serverAddress: String = "",
val serverPort: Int = 7000,
val token: String = "",
val remotePort: Int = 8080,
val customDomain: String = "",
)

// TODO(b/423700720): Change to async (suspend) functions
interface DataStoreRepository {
fun saveTextInputHistory(history: List<String>)
Expand Down Expand Up @@ -62,6 +77,14 @@ interface DataStoreRepository {
*/
fun readFirebaseAnalytics(): Boolean

fun saveOpenAiApiServerPreferences(preferences: OpenAiApiServerPreferences)

fun readOpenAiApiServerPreferences(): OpenAiApiServerPreferences

fun saveFrpPreferences(preferences: FrpPreferences)

fun readFrpPreferences(): FrpPreferences

fun saveSecret(key: String, value: String)

fun readSecret(key: String): String?
Expand Down Expand Up @@ -193,6 +216,60 @@ class DefaultDataStoreRepository(
}
}

override fun saveOpenAiApiServerPreferences(preferences: OpenAiApiServerPreferences) {
runBlocking {
dataStore.updateData { settings ->
settings
.toBuilder()
.setOpenaiApiServerEnabled(preferences.enabled)
.setOpenaiApiServerPort(preferences.port)
.setOpenaiApiServerModel(preferences.modelName)
.build()
}
}
}

override fun readOpenAiApiServerPreferences(): OpenAiApiServerPreferences {
return runBlocking {
val settings = dataStore.data.first()
OpenAiApiServerPreferences(
enabled = settings.openaiApiServerEnabled,
port = settings.openaiApiServerPort.takeIf { it in 1024..65535 } ?: 8080,
modelName = settings.openaiApiServerModel,
)
}
}

override fun saveFrpPreferences(preferences: FrpPreferences) {
runBlocking {
dataStore.updateData { settings ->
settings
.toBuilder()
.setFrpEnabled(preferences.enabled)
.setFrpServerAddress(preferences.serverAddress)
.setFrpServerPort(preferences.serverPort)
.setFrpToken(preferences.token)
.setFrpRemotePort(preferences.remotePort)
.setFrpCustomDomain(preferences.customDomain)
.build()
}
}
}

override fun readFrpPreferences(): FrpPreferences {
return runBlocking {
val settings = dataStore.data.first()
FrpPreferences(
enabled = settings.frpEnabled,
serverAddress = settings.frpServerAddress,
serverPort = settings.frpServerPort,
token = settings.frpToken,
remotePort = settings.frpRemotePort,
customDomain = settings.frpCustomDomain,
)
}
}

override fun saveSecret(key: String, value: String) {
runBlocking {
userDataDataStore.updateData { userData ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.ai.edge.gallery.server

import android.content.Context
import android.net.Uri
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

private const val TAG = "AGFrpManager"

@Singleton
class FrpManager @Inject constructor(@ApplicationContext private val context: Context) {
private var process: Process? = null
private val frpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()

fun isBinaryAvailable(): Boolean {
val binary = getFrpcBinary()
return binary != null && binary.exists()
}

fun importBinary(uri: Uri): Boolean {
return try {
val targetFile = File(context.filesDir, "frpc")
context.contentResolver.openInputStream(uri)?.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
}
targetFile.setExecutable(true)
Log.i(TAG, "frpc binary imported successfully to ${targetFile.absolutePath}")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to import frpc binary", e)
false
}
}

fun deleteBinary(): Boolean {
val file = File(context.filesDir, "frpc")
return if (file.exists()) {
file.delete()
} else {
true
}
}

fun start(serverAddr: String, serverPort: Int, token: String, localPort: Int, remotePort: Int, customDomain: String = "") {
if (_isRunning.value) stop()

val frpcBinary = getFrpcBinary()
if (frpcBinary == null || !frpcBinary.exists()) {
val msg = "frpc binary not found. Please run: adb push frpc /data/data/com.google.aiedge.gallery/files/frpc"
Log.e(TAG, msg)
return
}

if (!frpcBinary.canExecute()) {
frpcBinary.setExecutable(true)
}

val configFile = File(context.filesDir, "frpc.toml")
val proxyConfig = if (customDomain.isNotEmpty()) {
"""
type = "http"
localPort = $localPort
customDomains = ["$customDomain"]
"""
} else {
"""
type = "tcp"
localPort = $localPort
remotePort = $remotePort
"""
}

configFile.writeText("""
serverAddr = "$serverAddr"
serverPort = $serverPort
auth.token = "$token"

[[proxies]]
name = "openai-api"
$proxyConfig
""".trimIndent())

frpScope.launch {
try {
Log.i(TAG, "Starting frpc...")
val builder = ProcessBuilder(frpcBinary.absolutePath, "-c", configFile.absolutePath)
.directory(context.filesDir)
.redirectErrorStream(true)

val p = builder.start()
process = p
_isRunning.value = true

p.inputStream.bufferedReader().use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "frpc: $line")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error running frpc", e)
} finally {
_isRunning.value = false
process = null
}
}
}

fun stop() {
process?.destroy()
process = null
_isRunning.value = false
}

private fun getFrpcBinary(): File? {
// Expected binary name based on architecture
val arch = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: return null
val binaryName = when {
arch.contains("arm64") -> "frpc_android_arm64"
arch.contains("armeabi") -> "frpc_android_arm"
arch.contains("x86_64") -> "frpc_android_amd64"
else -> "frpc"
}

// Check in files directory
val file = File(context.filesDir, "frpc")
if (file.exists()) return file

val archFile = File(context.filesDir, binaryName)
if (archFile.exists()) return archFile

return null
}
}
Loading