From 8448b3f2a98747a530834161e6d1841420e15e34 Mon Sep 17 00:00:00 2001 From: Chloe <25387744+qimiko@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:44:29 -0700 Subject: [PATCH] i am curious --- app/build.gradle.kts | 7 + app/src/main/AndroidManifest.xml | 8 + app/src/main/assets/web_interface/index.html | 376 ++++++++++++++++++ .../com/customRobTop/BaseRobTopActivity.kt | 2 +- .../geode/launcher/DeveloperServerService.kt | 351 ++++++++++++++++ .../java/com/geode/launcher/MainActivity.kt | 26 +- .../com/geode/launcher/log/CrashViewModel.kt | 1 - .../java/com/geode/launcher/log/LogLine.kt | 34 +- .../preferences/DeveloperSettingsActivity.kt | 13 + .../preferences/SettingsComponents.kt | 7 +- .../geode/launcher/utils/PreferenceUtils.kt | 4 +- app/src/main/res/values/strings.xml | 6 + 12 files changed, 821 insertions(+), 14 deletions(-) create mode 100644 app/src/main/assets/web_interface/index.html create mode 100644 app/src/main/java/com/geode/launcher/DeveloperServerService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ae9c9cc..bb8a5328 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,13 @@ dependencies { implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.38.1") implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.38.1") implementation ("androidx.browser:browser:1.9.0") + + implementation ("io.ktor:ktor-server-core-jvm:3.3.3") + implementation ("io.ktor:ktor-server-cio:3.3.3") + implementation ("io.ktor:ktor-utils-jvm:3.3.3") + implementation ("io.ktor:ktor-server-status-pages:3.3.3") + implementation ("io.ktor:ktor-server-sse:3.3.3") + debugImplementation ("androidx.compose.ui:ui-tooling") coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8079524b..0ed038bc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/assets/web_interface/index.html b/app/src/main/assets/web_interface/index.html new file mode 100644 index 00000000..35f6d3ef --- /dev/null +++ b/app/src/main/assets/web_interface/index.html @@ -0,0 +1,376 @@ + + + + + + Geode Android Web Interface + + + + + + + + + +

+ + + Geode Android Manager +

+ +
+
+
+ + +
+ + + + + + +
+ +
+

+ + +
+
+ +
+

Logs

+ +

+    
+ + + + diff --git a/app/src/main/java/com/customRobTop/BaseRobTopActivity.kt b/app/src/main/java/com/customRobTop/BaseRobTopActivity.kt index 33f7b704..cbbf79ba 100644 --- a/app/src/main/java/com/customRobTop/BaseRobTopActivity.kt +++ b/app/src/main/java/com/customRobTop/BaseRobTopActivity.kt @@ -24,7 +24,7 @@ object BaseRobTopActivity { var isPaused = false - lateinit var me: WeakReference + var me: WeakReference = WeakReference(null) private var shouldResumeSound = true fun setCurrentActivity(currentActivity: Activity) { diff --git a/app/src/main/java/com/geode/launcher/DeveloperServerService.kt b/app/src/main/java/com/geode/launcher/DeveloperServerService.kt new file mode 100644 index 00000000..9fa389a3 --- /dev/null +++ b/app/src/main/java/com/geode/launcher/DeveloperServerService.kt @@ -0,0 +1,351 @@ +package com.geode.launcher + +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Binder +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import com.geode.launcher.log.LogLine +import com.geode.launcher.utils.DownloadUtils +import com.geode.launcher.utils.LaunchUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okio.Buffer +import okio.buffer +import okio.source +import java.io.EOFException +import java.io.File +import java.io.IOException +import java.nio.file.DirectoryNotEmptyException +import kotlin.collections.plusAssign +import kotlin.io.path.deleteExisting +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant +import io.ktor.http.* +import io.ktor.http.content.PartData +import io.ktor.http.content.forEachPart +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.engine.* +import io.ktor.server.cio.* +import io.ktor.server.http.content.staticFiles +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.request.receiveChannel +import io.ktor.server.request.receiveMultipart +import io.ktor.server.sse.* +import io.ktor.sse.ServerSentEvent +import io.ktor.util.cio.writeChannel +import io.ktor.utils.io.copyAndClose + +enum class FileType { + @SerialName("file") + FILE, + @SerialName("directory") + DIRECTORY +} + +@Serializable +data class FileMeta(val filename: String, val type: FileType, val size: Long, val lastModified: Instant) + +class DeveloperServerService : Service() { + private val binder = LocalBinder() + + private var server: EmbeddedServer? = null + + inner class LocalBinder : Binder() { + fun getService(): DeveloperServerService = this@DeveloperServerService + } + + private fun startForeground() { + try { + val notification = NotificationCompat.Builder(this, "geode_developer_server") + .setSmallIcon(R.drawable.geode_monochrome) + .build() + ServiceCompat.startForeground( + this, + 100, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + } catch (e: Exception) { + /* + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && e is ForegroundServiceStartNotAllowedException + ) { + println("idk") + } + */ + } + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onTimeout(startId: Int, fgsType: Int) { + stopSelf() + } + + override fun onDestroy() { + super.onDestroy() + + server?.stop() + server = null + } + + // not recursive copy + private fun copyIndexToCache() { + val folderName = "web_interface" + val base = File(application.cacheDir, folderName) + + base.deleteRecursively() + base.mkdirs() + + assets.list(folderName)?.forEach { + val assetName = "$folderName/$it" + val outputPath = File(base, it) + assets.open(assetName).use { inputStream -> + outputPath.outputStream().use { outputStream -> + DownloadUtils.copyFile(inputStream, outputStream) + } + } + } + } + + private fun createLogsFlow(): Flow = flow { + val timestamp = System.currentTimeMillis() + val seconds = timestamp / 1000 + val ms = timestamp % 1000 + + // Time format is 'MM-DD hh:mm:ss.mmm...', 'YYYY-MM-DD hh:mm:ss.mmm...', or 'sssss.mmm...'. + val timeString = "$seconds.$ms" + + val logCommand = "logcat -B -T $timeString" + + val logProcess = try { + Runtime.getRuntime().exec(logCommand) + } catch (ioe: IOException) { + ioe.printStackTrace() + emit(LogLine.showException(ioe)) + + return@flow + } + + val logBuffer = logProcess.inputStream.source().buffer() + + try { + // this runs until the stream is exhausted + while (currentCoroutineContext().isActive) { + val line = LogLine.fromBufferedSource(logBuffer) + emit(line) + + yield() + } + } catch (_: EOFException) { + // ignore, end of file reached + } catch (e: Exception) { + e.printStackTrace() + emit(LogLine.showException(e)) + } + } + + private suspend fun readLogs(): List { + val logLines = mutableListOf() + + val logCommand = "logcat -B -d" + val logProcess = try { + withContext(Dispatchers.IO) { + Runtime.getRuntime().exec(logCommand) + } + } catch (ioe: IOException) { + ioe.printStackTrace() + logLines += LogLine.showException(ioe) + + return logLines + } + + // read entire log into a buffer so no logs are added to the buffer during processing + val logBuffer = Buffer() + logProcess.inputStream.source().buffer().readAll(logBuffer) + + try { + coroutineScope { + // this runs until the stream is exhausted + while (true) { + val line = LogLine.fromBufferedSource(logBuffer) + logLines += line + + yield() + } + } + } catch (_: EOFException) { + // ignore, end of file reached + } catch (e: Exception) { + e.printStackTrace() + logLines += LogLine.showException(e) + } + + return logLines + } + + override fun onCreate() { + super.onCreate() + + val staticDir = File(application.cacheDir, "web_interface") + val baseDir = LaunchUtils.getBaseDirectory(application, true) + + val logsFlow = createLogsFlow() + + server = embeddedServer(CIO, port = 9043) { + routing { + install(SSE) + + install(StatusPages) { + exception { call, cause -> + call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError) + } + } + + get("/logs") { + val logs = Json.encodeToString(readLogs()) + call.respondText(logs, contentType = ContentType.Application.Json) + } + + post("/stop") { + stopSelf() + } + + sse("/logs_live") { + heartbeat { + period = 5000.milliseconds + event = ServerSentEvent(comments = "heartbeat") + } + logsFlow.collect { + val log = Json.encodeToString(it) + send(log, event = "log", id = it.logId.toString()) + } + } + + staticFiles("/", staticDir) + + staticFiles("/files", baseDir, index = null) { + fallback { requestedPath, call -> + val basePath = baseDir.toPath() + val builtPath = basePath.resolve(requestedPath).normalize() + if (!builtPath.startsWith(basePath) || !builtPath.exists()) { + // block path traversal issues + call.response.status(HttpStatusCode.NotFound) + } else { + val filenames = builtPath.toFile().listFiles()?.map { + FileMeta( + filename = it.name, + type = if (it.isDirectory) FileType.DIRECTORY else FileType.FILE, + size = it.length(), + lastModified = Instant.fromEpochMilliseconds(it.lastModified()) + ) + } ?: emptyList() + + val data = Json.encodeToString(filenames) + + call.respondText(data, contentType = ContentType.Application.Json) + } + } + } + + delete("/files/{path...}") { + val pathSegments = call.parameters.getAll("path") + val requestedPath = pathSegments?.joinToString("/") ?: "" + + val basePath = baseDir.toPath() + val builtPath = basePath.resolve(requestedPath).normalize() + if (!builtPath.startsWith(basePath) || !builtPath.exists()) { + call.response.status(HttpStatusCode.NotFound) + } else { + try { + builtPath.deleteExisting() + call.response.status(HttpStatusCode.NoContent) + } catch (e: DirectoryNotEmptyException) { + val message = Json.encodeToString(buildJsonObject { + put("message", "Provided directory $requestedPath was not empty") + }) + + call.respondText(message, status = HttpStatusCode.Conflict, contentType = ContentType.Application.Json) + } + } + } + + put("/files/{path...}") { + val pathSegments = call.parameters.getAll("path") + val requestedPath = pathSegments?.joinToString("/") ?: "" + + val basePath = baseDir.toPath() + val builtPath = basePath.resolve(requestedPath).normalize() + if (!builtPath.startsWith(basePath)) { + call.response.status(HttpStatusCode.NotFound) + } else { + if (builtPath.isDirectory()) { + val multipart = call.receiveMultipart(formFieldLimit = 1024 * 1024 * 100) + multipart.forEachPart { part -> + if (part is PartData.FileItem) { + val fileName = part.originalFileName ?: "uploaded_file" + + val outputPath = builtPath.resolve(fileName).normalize() + if (!outputPath.startsWith(basePath)) { + val outputFile = outputPath.toFile() + part.provider().copyAndClose(outputFile.writeChannel()) + } + } + part.dispose() + } + } else { + val outputFile = builtPath.toFile() + outputFile.parentFile?.mkdirs() + + call.receiveChannel().copyAndClose(outputFile.writeChannel()) + } + + call.response.status(HttpStatusCode.NoContent) + } + } + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + this.startForeground() + + copyIndexToCache() + server?.start(wait = false) + + return START_STICKY + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + + stopSelf() + } +} + diff --git a/app/src/main/java/com/geode/launcher/MainActivity.kt b/app/src/main/java/com/geode/launcher/MainActivity.kt index 420718b4..bf3d2b9c 100644 --- a/app/src/main/java/com/geode/launcher/MainActivity.kt +++ b/app/src/main/java/com/geode/launcher/MainActivity.kt @@ -1,6 +1,9 @@ package com.geode.launcher +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -25,18 +28,18 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.geode.launcher.main.* -import com.geode.launcher.updater.ReleaseViewModel import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.launcherTitleStyle +import com.geode.launcher.updater.ReleaseViewModel import com.geode.launcher.utils.Constants +import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.PreferenceUtils -import com.geode.launcher.utils.GamePackageUtils + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -61,6 +64,23 @@ class MainActivity : ComponentActivity() { LoadFailureInfo(LaunchUtils.LauncherError.CRASHED) } else { null } + if (PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DEVELOPER_SERVER)) { + val intent = Intent(this, DeveloperServerService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + + val name = getString(R.string.notification_channel_development_server) + val descriptionText = getString(R.string.notification_channel_development_server_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val mChannel = NotificationChannel("geode_developer_server", name, importance) + mChannel.description = descriptionText + + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(mChannel) + + this.startForegroundService(intent) + } + } + val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN) if (redirectToAlt) { val launchIntent = Intent(this, AltMainActivity::class.java) diff --git a/app/src/main/java/com/geode/launcher/log/CrashViewModel.kt b/app/src/main/java/com/geode/launcher/log/CrashViewModel.kt index 405e8cd7..d81105b9 100644 --- a/app/src/main/java/com/geode/launcher/log/CrashViewModel.kt +++ b/app/src/main/java/com/geode/launcher/log/CrashViewModel.kt @@ -45,7 +45,6 @@ class CrashViewModel(private val application: Application) : ViewModel() { private var logJob: Job? = null - @OptIn(ExperimentalTime::class) private fun getCrashList(): List { val crashDirectory = LaunchUtils.getCrashDirectory(application) if (!crashDirectory.exists()) { diff --git a/app/src/main/java/com/geode/launcher/log/LogLine.kt b/app/src/main/java/com/geode/launcher/log/LogLine.kt index 95db37ee..acc4159b 100644 --- a/app/src/main/java/com/geode/launcher/log/LogLine.kt +++ b/app/src/main/java/com/geode/launcher/log/LogLine.kt @@ -4,6 +4,9 @@ import kotlin.time.Clock import kotlin.time.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import okio.Buffer import okio.BufferedSource import java.io.IOException @@ -26,10 +29,29 @@ fun BufferedSource.readCString(): String { return buffer.toString() } +@Serializable data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int) +@Serializable enum class LogPriority { - UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT; + @SerialName("unknown") + UNKNOWN, + @SerialName("default") + DEFAULT, + @SerialName("verbose") + VERBOSE, + @SerialName("debug") + DEBUG, + @SerialName("info") + INFO, + @SerialName("warn") + WARN, + @SerialName("error") + ERROR, + @SerialName("fatal") + FATAL, + @SerialName("silent") + SILENT; class LogPriorityIterator( start: LogPriority, @@ -116,7 +138,8 @@ enum class LogPriority { /** * Represents a log entry from logcat. */ -data class LogLine @OptIn(ExperimentalTime::class) constructor( +@Serializable +data class LogLine( val process: ProcessInformation, val time: Instant, val logId: Int, @@ -198,11 +221,10 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor( time = time, logId = lid, tag = tag, - message = message + message = message.trimEnd('\u0000') ) } - @OptIn(ExperimentalTime::class) fun showException(exception: Exception) = LogLine( process = ProcessInformation(0, 0, 0), time = Clock.System.now(), @@ -213,15 +235,15 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor( ) } - @OptIn(ExperimentalTime::class) + @Transient val identifier = time.toJavaInstant() val messageTrimmed by lazy { this.message.trim { it <= ' ' } } - @OptIn(ExperimentalTime::class) val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) } + val asSimpleString by lazy { "$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}" } diff --git a/app/src/main/java/com/geode/launcher/preferences/DeveloperSettingsActivity.kt b/app/src/main/java/com/geode/launcher/preferences/DeveloperSettingsActivity.kt index 69419008..c0cd93e3 100644 --- a/app/src/main/java/com/geode/launcher/preferences/DeveloperSettingsActivity.kt +++ b/app/src/main/java/com/geode/launcher/preferences/DeveloperSettingsActivity.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.geode.launcher.R import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.LocalTheme @@ -192,6 +193,18 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) { description = LaunchUtils.getBaseDirectory(context).path, onClick = { onOpenFolder(context) } ) + + SettingsCard( + title = stringResource(R.string.preference_development_server_name), + description = stringResource(R.string.preference_development_server_description), + preferenceKey = PreferenceUtils.Key.DEVELOPER_SERVER, + ) { enabled -> + if (enabled) { + + } else { + + } + } } OptionsGroup(title = stringResource(R.string.preference_category_updater)) { diff --git a/app/src/main/java/com/geode/launcher/preferences/SettingsComponents.kt b/app/src/main/java/com/geode/launcher/preferences/SettingsComponents.kt index 73925c5c..0773ea47 100644 --- a/app/src/main/java/com/geode/launcher/preferences/SettingsComponents.kt +++ b/app/src/main/java/com/geode/launcher/preferences/SettingsComponents.kt @@ -925,7 +925,7 @@ fun OptionsLabel(title: String, description: String? = null, icon: (@Composable } @Composable -fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) { +fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key, onChange: ((Boolean) -> Unit)? = null) { val context = LocalContext.current val settingEnabled = remember { mutableStateOf(getSetting(context, preferenceKey)) @@ -941,7 +941,10 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable }, modifier = Modifier.toggleable( value = settingEnabled.value, - onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) }, + onValueChange = { + settingEnabled.value = toggleSetting(context, preferenceKey) + onChange?.invoke(settingEnabled.value) + }, role = Role.Switch, ), backgroundColor = if (asCard) diff --git a/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt b/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt index c138091d..632de909 100644 --- a/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt +++ b/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt @@ -149,7 +149,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) { SCREEN_ZOOM_FIT, LIMIT_FRAME_RATE, DISABLE_USER_THEME, - SELECTED_ICON + SELECTED_ICON, + DEVELOPER_SERVER, } private fun defaultValueForBooleanKey(key: Key): Boolean { @@ -200,6 +201,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) { Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate" Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme" Key.SELECTED_ICON -> "PreferenceSelectedIcon" + Key.DEVELOPER_SERVER -> "PreferenceDeveloperServer" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0bf627c..5c6952ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,12 @@ Uses exception_symbols.txt to patch Use index release API + Enable development server + Opens a public development server on port 9043. + + Development Server Service + Indicates the development server is currently active. + Crash dumps No crash dumps available.