From c937867bad4ca27587dcab23b3d0e269a09eff71 Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Tue, 20 Jan 2026 05:09:58 -0500 Subject: [PATCH] added option in settings to edit Supermodel.ini easy & raw --- android/app/src/main/AndroidManifest.xml | 10 + .../com/izzy2lost/super3/IniDocumentStore.kt | 100 +++++ .../com/izzy2lost/super3/IniEditorActivity.kt | 171 +++++++++ .../izzy2lost/super3/IniSettingsActivity.kt | 349 ++++++++++++++++++ .../java/com/izzy2lost/super3/MainActivity.kt | 26 ++ .../java/com/izzy2lost/super3/UserDataSync.kt | 2 +- .../main/res/layout/activity_ini_editor.xml | 64 ++++ .../main/res/layout/activity_ini_settings.xml | 134 +++++++ .../main/res/layout/dialog_number_input.xml | 20 + .../app/src/main/res/layout/nav_header.xml | 18 + .../app/src/main/res/menu/menu_ini_editor.xml | 9 + 11 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/izzy2lost/super3/IniDocumentStore.kt create mode 100644 android/app/src/main/java/com/izzy2lost/super3/IniEditorActivity.kt create mode 100644 android/app/src/main/java/com/izzy2lost/super3/IniSettingsActivity.kt create mode 100644 android/app/src/main/res/layout/activity_ini_editor.xml create mode 100644 android/app/src/main/res/layout/activity_ini_settings.xml create mode 100644 android/app/src/main/res/layout/dialog_number_input.xml create mode 100644 android/app/src/main/res/menu/menu_ini_editor.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fff0e4c..a597205 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,6 +26,16 @@ android:screenOrientation="fullSensor" android:exported="false" /> + + + + + val name = doc.name ?: return@mapNotNull null + if (!name.startsWith("Supermodel.ini", ignoreCase = true)) return@mapNotNull null + doc + } + if (candidates.isEmpty()) return null + + fun nameScore(doc: DocumentFile): Int { + val name = doc.name?.lowercase() ?: "" + return when { + name == "supermodel.ini" -> 0 + name == "supermodel.ini.txt" -> 1 + name.startsWith("supermodel.ini(") -> 2 + else -> 3 + } + } + + val best = + if (candidates.any { it.lastModified() > 0L }) { + candidates.maxWithOrNull( + compareBy { it.lastModified() } + .thenBy { -nameScore(it) } + .thenBy { -(it.name?.length ?: Int.MAX_VALUE) }, + ) + } else { + candidates.minWithOrNull( + compareBy { nameScore(it) } + .thenBy { it.name?.length ?: Int.MAX_VALUE }, + ) + } ?: return null + + val bestName = best.name ?: return best + if (!bestName.equals("Supermodel.ini", ignoreCase = true)) { + val renamed = runCatching { best.renameTo("Supermodel.ini") }.getOrDefault(false) + if (renamed) { + val renamedDoc = configDir.findFile("Supermodel.ini") + if (renamedDoc != null && renamedDoc.isFile) return renamedDoc + } + } + return best + } + + private fun seedIniDocument(context: Context, doc: DocumentFile) { + val internal = File(File(context.getExternalFilesDir(null), "super3/Config"), "Supermodel.ini") + val input = + when { + internal.exists() -> runCatching { internal.inputStream() }.getOrNull() + else -> runCatching { context.assets.open("Config/Supermodel.ini") }.getOrNull() + } + if (input == null) return + input.use { ins -> + context.contentResolver.openOutputStream(doc.uri)?.use { outs -> + ins.copyTo(outs) + } + } + } +} diff --git a/android/app/src/main/java/com/izzy2lost/super3/IniEditorActivity.kt b/android/app/src/main/java/com/izzy2lost/super3/IniEditorActivity.kt new file mode 100644 index 0000000..da84881 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/super3/IniEditorActivity.kt @@ -0,0 +1,171 @@ +package com.izzy2lost.super3 + +import android.net.Uri +import android.os.Bundle +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import androidx.activity.addCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.addTextChangedListener +import androidx.documentfile.provider.DocumentFile +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlin.concurrent.thread + +class IniEditorActivity : AppCompatActivity() { + companion object { + const val EXTRA_TREE_URI = "treeUri" + } + + private lateinit var toolbar: MaterialToolbar + private lateinit var pathText: TextView + private lateinit var editor: EditText + + private var iniDoc: DocumentFile? = null + private var suppressDirty = false + private var dirty = false + private var busy = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + applyImmersiveMode() + setContentView(R.layout.activity_ini_editor) + + toolbar = findViewById(R.id.ini_toolbar) + pathText = findViewById(R.id.ini_path) + editor = findViewById(R.id.ini_editor) + + toolbar.inflateMenu(R.menu.menu_ini_editor) + toolbar.setNavigationOnClickListener { maybeExit() } + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_save -> { + saveIni() + true + } + else -> false + } + } + tintMenuIcons() + updateSaveEnabled() + + editor.addTextChangedListener { + if (suppressDirty || busy) return@addTextChangedListener + if (!dirty) { + dirty = true + toolbar.subtitle = "Unsaved changes" + updateSaveEnabled() + } + } + + onBackPressedDispatcher.addCallback(this) { + maybeExit() + } + + val treeUri = intent.getStringExtra(EXTRA_TREE_URI)?.let(Uri::parse) + if (treeUri == null) { + Toast.makeText(this, "Data folder not set", Toast.LENGTH_SHORT).show() + finish() + return + } + pathText.text = "Editing: Config/Supermodel.ini\nData folder: $treeUri" + loadIni(treeUri) + } + + private fun maybeExit() { + if (busy) { + Toast.makeText(this, "Please wait...", Toast.LENGTH_SHORT).show() + return + } + if (!dirty) { + finish() + return + } + + MaterialAlertDialogBuilder(this) + .setTitle("Discard changes?") + .setMessage("You have unsaved changes to Supermodel.ini.") + .setPositiveButton("Save") { _, _ -> saveIni(finishAfter = true) } + .setNegativeButton("Discard") { _, _ -> finish() } + .setNeutralButton("Cancel", null) + .show() + } + + private fun loadIni(treeUri: Uri) { + setBusy(true, "Loading...") + thread(name = "Super3IniLoad") { + val doc = IniDocumentStore.ensureIniDocument(this, treeUri) + val text = doc?.let { IniDocumentStore.readIniText(this, it) } + runOnUiThread { + if (doc == null || text == null) { + setBusy(false, null) + Toast.makeText(this, "Failed to load Supermodel.ini", Toast.LENGTH_SHORT).show() + finish() + return@runOnUiThread + } + iniDoc = doc + suppressDirty = true + editor.setText(text) + editor.setSelection(0) + suppressDirty = false + dirty = false + toolbar.subtitle = null + updateSaveEnabled() + setBusy(false, null) + } + } + } + + private fun saveIni(finishAfter: Boolean = false) { + val doc = iniDoc ?: return + val text = editor.text?.toString() ?: "" + setBusy(true, "Saving...") + thread(name = "Super3IniSave") { + val ok = IniDocumentStore.writeIniText(this, doc, text) + runOnUiThread { + setBusy(false, null) + if (!ok) { + Toast.makeText(this, "Failed to save Supermodel.ini", Toast.LENGTH_SHORT).show() + return@runOnUiThread + } + dirty = false + toolbar.subtitle = "Saved" + updateSaveEnabled() + if (finishAfter) { + finish() + } + } + } + } + + private fun setBusy(isBusy: Boolean, status: String?) { + busy = isBusy + editor.isEnabled = !isBusy + toolbar.menu.findItem(R.id.action_save)?.isEnabled = !isBusy && dirty + if (status != null) { + toolbar.subtitle = status + } else if (!isBusy) { + toolbar.subtitle = null + } + } + + private fun updateSaveEnabled() { + val saveItem = toolbar.menu.findItem(R.id.action_save) + saveItem?.isEnabled = dirty && !busy + tintMenuIcons() + } + + private fun tintMenuIcons() { + val enabledColor = + MaterialColors.getColor(toolbar, com.google.android.material.R.attr.colorOnPrimary) + val disabledColor = + MaterialColors.getColor(toolbar, com.google.android.material.R.attr.colorOnSurfaceVariant) + val saveItem = toolbar.menu.findItem(R.id.action_save) + val tint = if (saveItem?.isEnabled == true) enabledColor else disabledColor + saveItem?.icon?.setTint(tint) + } + + // IniDocumentStore handles SAF reads/writes + file selection. +} diff --git a/android/app/src/main/java/com/izzy2lost/super3/IniSettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/super3/IniSettingsActivity.kt new file mode 100644 index 0000000..e450b72 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/super3/IniSettingsActivity.kt @@ -0,0 +1,349 @@ +package com.izzy2lost.super3 + +import android.net.Uri +import android.os.Bundle +import android.text.InputType +import android.widget.Toast +import androidx.activity.addCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.documentfile.provider.DocumentFile +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import kotlin.concurrent.thread + +class IniSettingsActivity : AppCompatActivity() { + private lateinit var toolbar: MaterialToolbar + private lateinit var btnPpcFrequency: MaterialButton + private lateinit var btnMultithreaded: MaterialButton + private lateinit var btnGpuMultithreaded: MaterialButton + private lateinit var btnVsync: MaterialButton + private lateinit var btnEmulateSound: MaterialButton + private lateinit var btnSoundVolume: MaterialButton + private lateinit var btnMusicVolume: MaterialButton + + private var iniDoc: DocumentFile? = null + private var iniLines: MutableList = mutableListOf() + private var busy = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + applyImmersiveMode() + setContentView(R.layout.activity_ini_settings) + + toolbar = findViewById(R.id.ini_settings_toolbar) + btnPpcFrequency = findViewById(R.id.btn_ppc_frequency) + btnMultithreaded = findViewById(R.id.btn_multithreaded) + btnGpuMultithreaded = findViewById(R.id.btn_gpu_multithreaded) + btnVsync = findViewById(R.id.btn_vsync) + btnEmulateSound = findViewById(R.id.btn_emulate_sound) + btnSoundVolume = findViewById(R.id.btn_sound_volume) + btnMusicVolume = findViewById(R.id.btn_music_volume) + + toolbar.setNavigationOnClickListener { finish() } + onBackPressedDispatcher.addCallback(this) { finish() } + + val treeUri = intent.getStringExtra(IniEditorActivity.EXTRA_TREE_URI)?.let(Uri::parse) + if (treeUri == null) { + Toast.makeText(this, "Data folder not set", Toast.LENGTH_SHORT).show() + finish() + return + } + + bindActions() + loadIni(treeUri) + } + + private fun bindActions() { + btnPpcFrequency.setOnClickListener { + val current = readIniInt("PowerPCFrequency") ?: 50 + showNumberDialog( + title = "PowerPC frequency", + key = "PowerPCFrequency", + current = current, + min = 10, + max = 200, + ) + } + + btnMultithreaded.setOnClickListener { + val enabled = btnMultithreaded.isChecked + applyUpdate( + updates = mapOf("MultiThreaded" to if (enabled) "1" else "0"), + onApplied = { btnMultithreaded.isChecked = enabled }, + onFailed = { btnMultithreaded.isChecked = !enabled }, + ) + } + + btnGpuMultithreaded.setOnClickListener { + val enabled = btnGpuMultithreaded.isChecked + applyUpdate( + updates = mapOf("GPUMultiThreaded" to if (enabled) "1" else "0"), + onApplied = { btnGpuMultithreaded.isChecked = enabled }, + onFailed = { btnGpuMultithreaded.isChecked = !enabled }, + ) + } + + btnVsync.setOnClickListener { + val enabled = btnVsync.isChecked + applyUpdate( + updates = mapOf("VSync" to if (enabled) "1" else "0"), + onApplied = { btnVsync.isChecked = enabled }, + onFailed = { btnVsync.isChecked = !enabled }, + ) + } + + btnEmulateSound.setOnClickListener { + val enabled = btnEmulateSound.isChecked + applyUpdate( + updates = mapOf("EmulateSound" to if (enabled) "1" else "0"), + onApplied = { btnEmulateSound.isChecked = enabled }, + onFailed = { btnEmulateSound.isChecked = !enabled }, + ) + } + + btnSoundVolume.setOnClickListener { + val current = readIniInt("SoundVolume") ?: 100 + showNumberDialog( + title = "Sound volume", + key = "SoundVolume", + current = current, + min = 0, + max = 200, + ) + } + + btnMusicVolume.setOnClickListener { + val current = readIniInt("MusicVolume") ?: 150 + showNumberDialog( + title = "Music volume", + key = "MusicVolume", + current = current, + min = 0, + max = 200, + ) + } + } + + private fun loadIni(treeUri: Uri) { + setBusy(true, "Loading...") + thread(name = "Super3IniSettingsLoad") { + val doc = IniDocumentStore.ensureIniDocument(this, treeUri) + val text = doc?.let { IniDocumentStore.readIniText(this, it) } + runOnUiThread { + if (doc == null || text == null) { + setBusy(false, null) + Toast.makeText(this, "Failed to load Supermodel.ini", Toast.LENGTH_SHORT).show() + finish() + return@runOnUiThread + } + iniDoc = doc + iniLines = text.split("\n").toMutableList() + applyUiFromIni() + setBusy(false, null) + } + } + } + + private fun applyUiFromIni() { + val ppc = readIniInt("PowerPCFrequency") ?: 50 + btnPpcFrequency.text = "PowerPC frequency: $ppc" + + btnMultithreaded.isChecked = readIniBool("MultiThreaded") ?: true + btnGpuMultithreaded.isChecked = readIniBool("GPUMultiThreaded") ?: false + btnVsync.isChecked = readIniBool("VSync") ?: true + btnEmulateSound.isChecked = readIniBool("EmulateSound") ?: true + + val sound = readIniInt("SoundVolume") ?: 100 + val music = readIniInt("MusicVolume") ?: 150 + btnSoundVolume.text = "Sound volume: $sound" + btnMusicVolume.text = "Music volume: $music" + } + + private fun showNumberDialog(title: String, key: String, current: Int, min: Int, max: Int) { + val view = layoutInflater.inflate(R.layout.dialog_number_input, null) + val inputLayout = view.findViewById(R.id.number_input_layout) + val input = view.findViewById(R.id.number_input) + inputLayout.hint = "$title ($min-$max)" + input.inputType = InputType.TYPE_CLASS_NUMBER + input.setText(current.toString()) + input.setSelection(input.text?.length ?: 0) + + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setView(view) + .setPositiveButton("Save") { _, _ -> + val raw = input.text?.toString()?.trim().orEmpty() + val value = raw.toIntOrNull() + if (value == null) { + Toast.makeText(this, "Enter a number between $min and $max", Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + val clamped = value.coerceIn(min, max) + if (clamped != value) { + Toast.makeText(this, "Clamped to $clamped", Toast.LENGTH_SHORT).show() + } + applyUpdate( + updates = mapOf(key to clamped.toString()), + onApplied = { + when (key) { + "PowerPCFrequency" -> btnPpcFrequency.text = "PowerPC frequency: $clamped" + "SoundVolume" -> btnSoundVolume.text = "Sound volume: $clamped" + "MusicVolume" -> btnMusicVolume.text = "Music volume: $clamped" + } + }, + ) + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun applyUpdate( + updates: Map, + onApplied: () -> Unit, + onFailed: (() -> Unit)? = null, + ) { + if (busy) return + val doc = iniDoc ?: return + val updated = updateIniSection(iniLines, "global", updates) + setBusy(true, "Saving...") + thread(name = "Super3IniSettingsSave") { + val ok = IniDocumentStore.writeIniText(this, doc, updated.joinToString("\n")) + runOnUiThread { + setBusy(false, null) + if (!ok) { + Toast.makeText(this, "Failed to save Supermodel.ini", Toast.LENGTH_SHORT).show() + onFailed?.invoke() + return@runOnUiThread + } + iniLines = updated + onApplied() + Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show() + } + } + } + + private fun readIniInt(key: String): Int? { + return readIniString(key)?.trim()?.toIntOrNull() + } + + private fun readIniBool(key: String): Boolean? { + val v = readIniString(key)?.trim()?.lowercase() ?: return null + return when (v) { + "1", "true", "yes", "on" -> true + "0", "false", "no", "off" -> false + else -> null + } + } + + private fun readIniString(key: String): String? { + val range = findSectionRange("global") ?: (0 until iniLines.size) + for (i in range) { + val line = iniLines[i] + val trimmed = line.trim() + if (trimmed.isBlank() || trimmed.startsWith(";")) continue + val rx = Regex("^\\s*${Regex.escape(key)}\\s*=", RegexOption.IGNORE_CASE) + if (rx.containsMatchIn(line)) { + val idx = line.indexOf("=") + return if (idx >= 0) line.substring(idx + 1).trim() else null + } + } + return null + } + + private fun updateIniSection( + lines: List, + section: String, + updates: Map, + ): MutableList { + val (start, end) = findSectionBounds(lines, section) ?: run { + val out = ArrayList(lines.size + updates.size + 2) + out.addAll(lines) + if (out.isNotEmpty() && out.last().isNotBlank()) out.add("") + out.add("[ Global ]") + for ((k, v) in updates) { + out.add("$k = $v") + } + return out + } + + val out = ArrayList(lines.size + updates.size) + out.addAll(lines.take(start + 1)) + + val existing = HashSet(updates.size) + for (i in (start + 1) until end) { + val line = lines[i] + val trimmed = line.trim() + if (trimmed.startsWith(";") || trimmed.isBlank()) { + out.add(line) + continue + } + var replaced = false + for ((k, v) in updates) { + val rx = Regex("^\\s*${Regex.escape(k)}\\s*=", RegexOption.IGNORE_CASE) + if (rx.containsMatchIn(line)) { + out.add("$k = $v") + existing.add(k.lowercase()) + replaced = true + break + } + } + if (!replaced) out.add(line) + } + + for ((k, v) in updates) { + if (existing.contains(k.lowercase())) continue + out.add("$k = $v") + } + + out.addAll(lines.drop(end)) + return out + } + + private fun findSectionRange(section: String): IntRange? { + val (start, end) = findSectionBounds(iniLines, section) ?: return null + return (start + 1) until end + } + + private fun findSectionBounds(lines: List, section: String): Pair? { + val target = section.lowercase() + var start = -1 + for (i in lines.indices) { + val name = sectionName(lines[i]) ?: continue + if (name == target) { + start = i + break + } + } + if (start < 0) return null + var end = lines.size + for (i in (start + 1) until lines.size) { + if (sectionName(lines[i]) != null) { + end = i + break + } + } + return start to end + } + + private fun sectionName(line: String): String? { + val trimmed = line.trim() + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null + return trimmed.substring(1, trimmed.length - 1).trim().lowercase() + } + + private fun setBusy(isBusy: Boolean, status: String?) { + busy = isBusy + toolbar.subtitle = status ?: if (busy) toolbar.subtitle else "Easy settings" + val enabled = !isBusy + btnPpcFrequency.isEnabled = enabled + btnMultithreaded.isEnabled = enabled + btnGpuMultithreaded.isEnabled = enabled + btnVsync.isEnabled = enabled + btnEmulateSound.isEnabled = enabled + btnSoundVolume.isEnabled = enabled + btnMusicVolume.isEnabled = enabled + } +} diff --git a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt index 64cec3f..bd5821c 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt @@ -173,6 +173,7 @@ class MainActivity : AppCompatActivity() { val btnShowShifterOverlay: MaterialButton = headerView.findViewById(R.id.btn_show_shifter_overlay) val btnGyroSteering: MaterialButton = headerView.findViewById(R.id.btn_gyro_steering) val btnGyroSensitivity: MaterialButton = headerView.findViewById(R.id.btn_gyro_sensitivity) + val btnEditSupermodelIni: MaterialButton = headerView.findViewById(R.id.btn_edit_supermodel_ini) gamesAdapter = GamesAdapter { item -> if (!item.launchable) { @@ -197,6 +198,31 @@ class MainActivity : AppCompatActivity() { btnPickUserFolder.setOnClickListener { pickUserFolder.launch(null) } btnRescan.setOnClickListener { refreshUi() } + btnEditSupermodelIni.setOnClickListener { + val tree = userTreeUri + if (tree == null) { + Toast.makeText(this, "Pick a data folder first", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + drawerLayout.closeDrawer(GravityCompat.START) + val options = arrayOf("Easy settings", "Raw text") + MaterialAlertDialogBuilder(this) + .setTitle("Edit Supermodel.ini") + .setItems(options) { _, which -> + val target = + when (which) { + 0 -> IniSettingsActivity::class.java + else -> IniEditorActivity::class.java + } + val intent = + Intent(this, target).apply { + putExtra(IniEditorActivity.EXTRA_TREE_URI, tree.toString()) + } + startActivity(intent) + } + .show() + } + bindVideoSettingsUi() bindTimingUi() diff --git a/android/app/src/main/java/com/izzy2lost/super3/UserDataSync.kt b/android/app/src/main/java/com/izzy2lost/super3/UserDataSync.kt index 6221cb3..a07bef8 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/UserDataSync.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/UserDataSync.kt @@ -83,7 +83,7 @@ object UserDataSync { private fun copyFileToDocFile(resolver: ContentResolver, from: File, toDir: DocumentFile) { val existing = toDir.findFile(from.name) val outDoc = existing ?: toDir.createFile("application/octet-stream", from.name) ?: return - resolver.openOutputStream(outDoc.uri, "wt")?.use { output -> + resolver.openOutputStream(outDoc.uri)?.use { output -> from.inputStream().use { input -> input.copyTo(output) } diff --git a/android/app/src/main/res/layout/activity_ini_editor.xml b/android/app/src/main/res/layout/activity_ini_editor.xml new file mode 100644 index 0000000..d466a6c --- /dev/null +++ b/android/app/src/main/res/layout/activity_ini_editor.xml @@ -0,0 +1,64 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_ini_settings.xml b/android/app/src/main/res/layout/activity_ini_settings.xml new file mode 100644 index 0000000..af8bac7 --- /dev/null +++ b/android/app/src/main/res/layout/activity_ini_settings.xml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_number_input.xml b/android/app/src/main/res/layout/dialog_number_input.xml new file mode 100644 index 0000000..284ecce --- /dev/null +++ b/android/app/src/main/res/layout/dialog_number_input.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/layout/nav_header.xml b/android/app/src/main/res/layout/nav_header.xml index 6f0d014..a3c17a6 100644 --- a/android/app/src/main/res/layout/nav_header.xml +++ b/android/app/src/main/res/layout/nav_header.xml @@ -172,6 +172,24 @@ android:text="Gyro sensitivity: Normal" style="?attr/materialButtonElevatedStyle" /> + + + + + + +