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
+
+
+
+
+
+
+
+
+
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.