diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..d2939ce
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,9 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+/deploymentTargetDropDown.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..fb7f4a8
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..e76fb07
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..44ca2d9
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..8d81632
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/ktfmt.xml b/.idea/ktfmt.xml
new file mode 100644
index 0000000..f140a53
--- /dev/null
+++ b/.idea/ktfmt.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..a14f9ca
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml
new file mode 100644
index 0000000..2b63946
--- /dev/null
+++ b/.idea/uiDesigner.xml
@@ -0,0 +1,124 @@
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 2a94ace..924e472 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,25 @@
# OdinTools
-Collection of utilities for the AYN Odin 2. This is a release repo as the app is currently closed source until I clean it up a bit more :)
+Collection of utilities for the AYN Odin 2.
-Latest release: **[0.3.0](https://github.com/langerhans/OdinTools/releases/latest)**
+Latest release: **[1.0.1](https://github.com/langerhans/OdinTools/releases/latest)**
### Features
- Quick settings tile for switching the controller style with user selectable option for which styles to cycle through
- Quick settings tile for switching the L2/R2 style with user selectable option for which styles to cycle through
- Single press home button setting to allow going to the home screen by a single press of the home button
- Display saturation setting. This is set immediately and will be applied after a reboot as well. Note that it takes a few seconds after you first unlock the device to apply.
+- Per-app overrides for both controller style and L2/R2 style settings. These will be applied and reverted automatically based on the current foreground app
### Contact
If you have any questions, feel free to find me on the AYN Discord at https://discord.com/invite/pWCpvEUTdR
### Compatibility
-This app has been tested with the Odin 2 firmware version **1.0.0.208**. It's possible that AYN breaks the methods used by this app in a future update. The app will warn you accordingly if that's the case.
+This app has been tested with the Odin 2 firmware version **1.0.0.253**. It's possible that AYN breaks the methods used by this app in a future update. The app will warn you accordingly if that's the case.
### Screenshots
-![image](https://github.com/langerhans/OdinTools/assets/5160000/a977def1-b7b3-40a3-a3de-0ea13aa836db)
-![image](https://github.com/langerhans/OdinTools/assets/5160000/bc0ff9bf-d4b8-424a-8633-b2413e475aaf)
-
+#### Main menu
+![image](docs/main.png)
+#### QS tiles
+![image](docs/qs_tiles.png)
+#### App overrides
+![image](docs/app_overrides.png)
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..67e07b8
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,2 @@
+/build
+/release
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..2757fa8
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,81 @@
+plugins {
+ alias(libs.plugins.com.android.application)
+ alias(libs.plugins.org.jetbrains.kotlin.android)
+ alias(libs.plugins.com.google.dagger.hilt.android)
+ alias(libs.plugins.com.google.devtools.ksp)
+ alias(libs.plugins.androidx.room)
+}
+
+android {
+ namespace = "de.langerhans.odintools"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "de.langerhans.odintools"
+ minSdk = 33
+ targetSdk = 34
+ versionCode = 6
+ versionName = "1.0.1"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ initWith(buildTypes.getByName("debug"))
+ isMinifyEnabled = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ applicationVariants.all {
+ val variant = this
+ outputs
+ .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
+ .forEach {
+ it.outputFileName = "OdinTools-${variant.versionName}.apk"
+ }
+ }
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+room {
+ schemaDirectory("$projectDir/schemas")
+}
+
+kotlin {
+ jvmToolchain(11)
+}
+
+dependencies {
+ // Compose BOM specifics
+ val composeBom = platform(libs.androidx.compose.bom)
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+ debugImplementation(composeBom)
+
+ // Normal imports
+ implementation(libs.bundles.app)
+ debugImplementation(libs.bundles.appDebug)
+ annotationProcessor(libs.bundles.appAnnotationProcessor)
+ ksp(libs.bundles.appKsp)
+ testImplementation(libs.bundles.appUnitTest)
+ androidTestImplementation(libs.bundles.appAndroidTest)
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/schemas/de.langerhans.odintools.data.AppDatabase/1.json b/app/schemas/de.langerhans.odintools.data.AppDatabase/1.json
new file mode 100644
index 0000000..5501f62
--- /dev/null
+++ b/app/schemas/de.langerhans.odintools.data.AppDatabase/1.json
@@ -0,0 +1,46 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "b70c67b243e7ce05a249a9113faa20ea",
+ "entities": [
+ {
+ "tableName": "appoverride",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `controllerStyle` TEXT, `l2R2Style` TEXT, PRIMARY KEY(`packageName`))",
+ "fields": [
+ {
+ "fieldPath": "packageName",
+ "columnName": "packageName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "controllerStyle",
+ "columnName": "controllerStyle",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "l2R2Style",
+ "columnName": "l2R2Style",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "packageName"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b70c67b243e7ce05a249a9113faa20ea')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/de/langerhans/odintools/ExampleInstrumentedTest.kt b/app/src/androidTest/java/de/langerhans/odintools/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..819d5b0
--- /dev/null
+++ b/app/src/androidTest/java/de/langerhans/odintools/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package de.langerhans.odintools
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("de.langerhans.odintools", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6e68d4b
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/OdinToolsApplication.kt b/app/src/main/java/de/langerhans/odintools/OdinToolsApplication.kt
new file mode 100644
index 0000000..11d7fc4
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/OdinToolsApplication.kt
@@ -0,0 +1,7 @@
+package de.langerhans.odintools
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class OdinToolsApplication : Application()
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListScreen.kt b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListScreen.kt
new file mode 100644
index 0000000..5306a63
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListScreen.kt
@@ -0,0 +1,139 @@
+package de.langerhans.odintools.appsettings
+
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+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.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+import de.langerhans.odintools.R
+import de.langerhans.odintools.ui.composables.DialogButton
+import de.langerhans.odintools.ui.composables.OdinTopAppBar
+
+@Composable
+fun AppOverrideListScreen(
+ viewModel: AppOverrideListViewModel = hiltViewModel(),
+ navigateToOverrides: (packageName: String) -> Unit
+) {
+ val uiState: AppOverrideListUiModel by viewModel.uiState.collectAsState()
+ Scaffold(topBar = { OdinTopAppBar(deviceVersion = uiState.deviceVersion) }) { contentPadding ->
+
+ if (uiState.showAppSelectDialog) {
+ AppPickerDialog(
+ uiState.overrideCandidates,
+ {
+ viewModel.dismissAppSelectDialog()
+ navigateToOverrides(it)
+ }, {
+ viewModel.dismissAppSelectDialog()
+ }
+ )
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(contentPadding)
+ ) {
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
+ .fillMaxWidth()
+ .clickable { viewModel.addClicked() }
+ .padding(vertical = 12.dp, horizontal = 16.dp)
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_add),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(LocalContentColor.current),
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = stringResource(id = R.string.addOverride),
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ }
+ }
+ items(items = uiState.overrideList, itemContent = {
+ AppItem(
+ it.packageName,
+ it.appName,
+ it.appIcon,
+ 48.dp,
+ it.subtitle
+ ) { packageName ->
+ navigateToOverrides(packageName)
+ }
+ })
+ }
+ }
+}
+
+@Composable
+fun AppItem(
+ packageName: String,
+ label: String,
+ icon: Drawable,
+ iconSize: Dp = 48.dp,
+ subLabel: String? = "",
+ onClick: (String) -> Unit
+) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick(packageName) }
+ .padding(vertical = 12.dp, horizontal = 16.dp)
+ ) {
+ Image(painter = rememberDrawablePainter(drawable = icon), contentDescription = null, Modifier.size(iconSize))
+ Column(modifier = Modifier.padding(start = 16.dp)) {
+ Text(text = label, modifier = Modifier.padding(bottom = 4.dp))
+ if (subLabel?.isNotEmpty() == true) {
+ Text(
+ text = subLabel,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun AppPickerDialog(
+ apps: List,
+ onAppSelected: (String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(onDismissRequest = {}, confirmButton = { }, dismissButton = {
+ DialogButton(text = stringResource(id = R.string.cancel), onDismiss)
+ }, title = {}, text = {
+ if (apps.isEmpty()) {
+ Text(text = stringResource(id = R.string.noOverrideCandidates))
+ return@AlertDialog
+ }
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ items(items = apps, itemContent = {
+ AppItem(
+ it.packageName,
+ it.appName,
+ it.appIcon,
+ 36.dp,
+ "",
+ onAppSelected
+ )
+ })
+ }
+ })
+}
diff --git a/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListUiModel.kt b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListUiModel.kt
new file mode 100644
index 0000000..3371969
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListUiModel.kt
@@ -0,0 +1,31 @@
+package de.langerhans.odintools.appsettings
+
+import android.graphics.drawable.Drawable
+import de.langerhans.odintools.models.ControllerStyle
+import de.langerhans.odintools.models.L2R2Style
+
+data class AppOverrideListUiModel(
+ val deviceVersion: String = "",
+ val showAppSelectDialog: Boolean = false,
+ val overrideList: List = emptyList(),
+ val overrideCandidates: List = emptyList()
+)
+
+data class AppOverridesUiModel(
+ val deviceVersion: String = "",
+ val app: AppUiModel? = null,
+
+ val hasUnsavedChanges: Boolean = false,
+ val showDeleteConfirmDialog: Boolean = false,
+ val navigateBack: Boolean = false,
+ val isNewApp: Boolean = false,
+)
+
+data class AppUiModel(
+ val packageName: String,
+ val appName: String,
+ val appIcon: Drawable,
+ val subtitle: String? = null,
+ val controllerStyle: ControllerStyle? = null,
+ val l2r2Style: L2R2Style? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListViewModel.kt b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListViewModel.kt
new file mode 100644
index 0000000..7eafe45
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideListViewModel.kt
@@ -0,0 +1,59 @@
+package de.langerhans.odintools.appsettings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.langerhans.odintools.data.AppOverrideDao
+import de.langerhans.odintools.tools.DeviceUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AppOverrideListViewModel @Inject constructor(
+ private val appOverrideDao: AppOverrideDao,
+ private val appOverrideMapper: AppOverrideMapper,
+ private val deviceUtils: DeviceUtils
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AppOverrideListUiModel())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var existingOverrides = emptyList()
+
+ init {
+ viewModelScope.launch {
+ appOverrideDao.getAll()
+ .flowOn(Dispatchers.IO)
+ .collect { overrides ->
+ existingOverrides = overrides.map { it.packageName }
+ _uiState.update {
+ it.copy(
+ overrideList = appOverrideMapper.mapAppOverrides(overrides),
+ overrideCandidates = appOverrideMapper.mapOverrideCandidates(overrides),
+ deviceVersion = deviceUtils.getDeviceVersion()
+ )
+ }
+ }
+ }
+ }
+
+ fun addClicked() {
+ _uiState.update {
+ it.copy(showAppSelectDialog = true)
+ }
+ }
+
+ fun dismissAppSelectDialog() {
+ _uiState.update {
+ it.copy(showAppSelectDialog = false)
+ }
+ }
+
+ /**
+ * System settings:
+ * performance_mode: standard 0, performance 1, high performance 2
+ * fan_mode: disabled 0, quiet 1, (balance 2), (performance 3), smart 4, sport 5, custom 6
+ */
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideMapper.kt b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideMapper.kt
new file mode 100644
index 0000000..2e3261c
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverrideMapper.kt
@@ -0,0 +1,84 @@
+package de.langerhans.odintools.appsettings
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.langerhans.odintools.R
+import de.langerhans.odintools.data.AppOverrideEntity
+import de.langerhans.odintools.models.ControllerStyle
+import de.langerhans.odintools.models.L2R2Style
+import javax.inject.Inject
+
+class AppOverrideMapper @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ fun mapOverrideCandidates(
+ existingOverrides: List
+ ): List {
+ return context.packageManager.getInstalledApplications(PackageManager.GET_META_DATA).filter {
+ it.flags and ApplicationInfo.FLAG_SYSTEM == 0 && it.enabled
+ }.filterNot { appInfo ->
+ existingOverrides.any { appInfo.packageName == it.packageName }
+ }.map {
+ val icon = context.packageManager.getApplicationIcon(it.packageName)
+ val label = context.packageManager.getApplicationLabel(it).toString()
+ AppUiModel(it.packageName, label, icon)
+ }.sortedBy {
+ it.appName
+ }
+ }
+
+ fun mapAppOverrides(overrides: List): List {
+ return overrides.mapNotNull(::mapAppOverride)
+ }
+
+ fun mapAppOverride(app: AppOverrideEntity): AppUiModel? {
+ val appInfo = runCatching {
+ context.packageManager.getApplicationInfo(app.packageName, PackageManager.GET_META_DATA)
+ }.onFailure { return null }.getOrNull() ?: return null
+ // TODO do DB cleanup on uninstalled packages?
+
+ val controllerStyle = ControllerStyle.getById(app.controllerStyle)
+ val l2R2Style = L2R2Style.getById(app.l2R2Style)
+ return AppUiModel(
+ packageName = app.packageName,
+ appName = context.packageManager.getApplicationLabel(appInfo).toString(),
+ appIcon = context.packageManager.getApplicationIcon(appInfo),
+ subtitle = getSubtitle(controllerStyle, l2R2Style),
+ controllerStyle = controllerStyle,
+ l2r2Style = l2R2Style
+ )
+ }
+
+ fun mapEmptyOverride(
+ packageName: String
+ ): AppUiModel {
+ // If this crashes then something is fishy...
+ val appInfo = context.packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
+
+ return AppUiModel(
+ packageName = packageName,
+ appName = context.packageManager.getApplicationLabel(appInfo).toString(),
+ appIcon = context.packageManager.getApplicationIcon(appInfo),
+ )
+ }
+
+ private fun getSubtitle(controllerStyle: ControllerStyle, l2R2Style: L2R2Style): String? {
+ return buildString {
+ if (controllerStyle != ControllerStyle.Unknown) {
+ append(context.getString(R.string.controllerStyle))
+ append(": ")
+ append(context.getString(controllerStyle.textRes))
+ append(" | ")
+ }
+ if (l2R2Style != L2R2Style.Unknown) {
+ append(context.getString(R.string.l2r2mode))
+ append(": ")
+ append(context.getString(l2R2Style.textRes))
+ append(" | ")
+ }
+ }.trimEnd(' ', '|').ifEmpty { null }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/appsettings/AppOverridesScreen.kt b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverridesScreen.kt
new file mode 100644
index 0000000..9f7ee3c
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverridesScreen.kt
@@ -0,0 +1,187 @@
+package de.langerhans.odintools.appsettings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+import de.langerhans.odintools.R
+import de.langerhans.odintools.models.ControllerStyle.*
+import de.langerhans.odintools.models.L2R2Style.*
+import de.langerhans.odintools.models.NoChange
+import de.langerhans.odintools.ui.composables.DeleteConfirmDialog
+import de.langerhans.odintools.ui.composables.LargeDropdownMenu
+import de.langerhans.odintools.ui.composables.OdinTopAppBar
+
+@Composable
+fun AppOverridesScreen(
+ viewModel: AppOverridesViewModel = hiltViewModel(),
+ navigateBack: () -> Unit
+) {
+ val uiState: AppOverridesUiModel by viewModel.uiState.collectAsState()
+ val app = uiState.app ?: return run {
+ // Shouldn't happen
+ }
+
+ Scaffold(topBar = { OdinTopAppBar(deviceVersion = uiState.deviceVersion) }) { contentPadding ->
+ LaunchedEffect(uiState.navigateBack) {
+ if (uiState.navigateBack) navigateBack()
+ }
+
+ if (uiState.showDeleteConfirmDialog) {
+ DeleteConfirmDialog(
+ onDelete = { viewModel.deleteConfirmed() },
+ onDismiss = { viewModel.deleteDismissed() }
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(contentPadding)
+ .padding(16.dp)
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .weight(0.3f)
+ .align(Alignment.CenterVertically)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.weight(1f)
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ painter = rememberDrawablePainter(drawable = app.appIcon),
+ contentDescription = null,
+ modifier = Modifier
+ .size(72.dp)
+ .padding(bottom = 8.dp)
+ )
+ Text(
+ text = app.appName,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ }
+ }
+ Button(
+ onClick = { viewModel.saveClicked() },
+ enabled = uiState.hasUnsavedChanges,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp)
+ ) {
+ Text(text = stringResource(id = R.string.save))
+ }
+ FilledTonalButton(
+ onClick = navigateBack,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = if (uiState.isNewApp) 0.dp else 8.dp)
+ ) {
+ Text(text = stringResource(id = R.string.cancel))
+ }
+ if (uiState.isNewApp.not()) {
+ OutlinedButton(
+ onClick = { viewModel.deleteClicked() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ color = Color.Red,
+ text = stringResource(id = R.string.deleteOverride)
+ )
+ }
+ }
+ }
+ Divider(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxHeight()
+ .width(1.dp)
+ )
+ Column(
+ modifier = Modifier.weight(0.7f)
+ ) {
+ OverrideSpinnerRow(
+ label = R.string.controllerStyle,
+ spinnerItems = listOf(
+ NoChange.KEY to stringResource(id = NoChange.textRes),
+ Odin.id to stringResource(id = Odin.textRes),
+ Xbox.id to stringResource(id = Xbox.textRes),
+ Disconnect.id to stringResource(id = Disconnect.textRes),
+ ),
+ initialSelection = uiState.app?.controllerStyle?.id ?: NoChange.KEY,
+ onSelectionChanged = { viewModel.controllerStyleSelected(it) },
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ OverrideSpinnerRow(
+ label = R.string.l2r2mode,
+ spinnerItems = listOf(
+ NoChange.KEY to stringResource(id = NoChange.textRes),
+ Analog.id to stringResource(id = Analog.textRes),
+ Digital.id to stringResource(id = Digital.textRes),
+ Both.id to stringResource(id = Both.textRes),
+ ),
+ initialSelection = uiState.app?.l2r2Style?.id ?: NoChange.KEY,
+ onSelectionChanged = { viewModel.l2R2StyleSelected(it) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun Spinner(
+ options: List>,
+ initialSelection: String,
+ onSelectionChanged: (key: String) -> Unit
+) {
+ val initial = options.indexOfFirst { it.first == initialSelection }.coerceAtLeast(0)
+ var selectedIndex by remember { mutableIntStateOf(initial) }
+
+ LargeDropdownMenu(
+ items = options,
+ onItemSelected = { index: Int, item: Pair ->
+ selectedIndex = index
+ onSelectionChanged(item.first)
+ },
+ selectedIndex = selectedIndex,
+ selectedItemToString = { it.second }
+ )
+}
+
+@Composable
+fun OverrideSpinnerRow(
+ @StringRes label: Int,
+ spinnerItems: List>,
+ initialSelection: String,
+ onSelectionChanged: (key: String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ ) {
+ Text(
+ text = stringResource(id = label),
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp)
+ )
+ Spinner(
+ options = spinnerItems,
+ initialSelection = initialSelection,
+ onSelectionChanged = onSelectionChanged
+ )
+ }
+}
diff --git a/app/src/main/java/de/langerhans/odintools/appsettings/AppOverridesViewModel.kt b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverridesViewModel.kt
new file mode 100644
index 0000000..1203f5b
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/appsettings/AppOverridesViewModel.kt
@@ -0,0 +1,135 @@
+package de.langerhans.odintools.appsettings
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import dagger.Module
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.langerhans.odintools.data.AppOverrideDao
+import de.langerhans.odintools.data.AppOverrideEntity
+import de.langerhans.odintools.models.ControllerStyle
+import de.langerhans.odintools.models.L2R2Style
+import de.langerhans.odintools.models.NoChange
+import de.langerhans.odintools.tools.DeviceUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class AppOverridesViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val appOverrideDao: AppOverrideDao,
+ private val appOverrideMapper: AppOverrideMapper,
+ private val deviceUtils: DeviceUtils
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AppOverridesUiModel())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val packageName = checkNotNull(savedStateHandle.get("packageName"))
+ private var initialControllerStyle = NoChange.KEY
+ private var initialL2R2Style = NoChange.KEY
+
+ init {
+ viewModelScope.launch {
+ val app = withContext(Dispatchers.IO) {
+ appOverrideDao.getForPackage(packageName)
+ }
+
+ val uiModel = if (app == null) {
+ appOverrideMapper.mapEmptyOverride(packageName)
+ } else {
+ initialControllerStyle = app.controllerStyle ?: NoChange.KEY
+ initialL2R2Style = app.l2R2Style ?: NoChange.KEY
+
+ appOverrideMapper.mapAppOverride(app)
+ }
+
+ _uiState.update {
+ it.copy(app = uiModel, isNewApp = app == null, deviceVersion = deviceUtils.getDeviceVersion())
+ }
+ }
+ }
+
+ fun saveClicked() {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ appOverrideDao.save(AppOverrideEntity(
+ packageName = packageName,
+ controllerStyle = _uiState.value.app?.controllerStyle?.id,
+ l2R2Style = _uiState.value.app?.l2r2Style?.id
+ ))
+ }
+ }
+ _uiState.update {
+ it.copy(navigateBack = true)
+ }
+ }
+
+ fun deleteClicked() {
+ _uiState.update {
+ it.copy(showDeleteConfirmDialog = true)
+ }
+ }
+
+ fun deleteDismissed() {
+ _uiState.update {
+ it.copy(showDeleteConfirmDialog = false)
+ }
+ }
+
+ fun deleteConfirmed() {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ appOverrideDao.deleteByPackageName(packageName)
+ }
+ }
+ _uiState.update {
+ it.copy(showDeleteConfirmDialog = false, navigateBack = true)
+ }
+ }
+
+ fun controllerStyleSelected(key: String) {
+ _uiState.update {
+ it.copy(
+ app = it.app?.copy(controllerStyle = ControllerStyle.getById(key)),
+ hasUnsavedChanges = hasUnsavedChanges(controllerStyle = key)
+ )
+ }
+ }
+
+ fun l2R2StyleSelected(key: String) {
+ _uiState.update {
+ it.copy(
+ app = it.app?.copy(l2r2Style = L2R2Style.getById(key)),
+ hasUnsavedChanges = hasUnsavedChanges(l2R2Style = key)
+ )
+ }
+ }
+
+ private fun hasUnsavedChanges(
+ controllerStyle: String? = null,
+ l2R2Style: String? = null
+ ): Boolean {
+ // Cascade through all possibly changed options. One should hit. If none hit the dev was an idiot.
+ controllerStyle?.let {
+ return it != initialControllerStyle
+ }
+ l2R2Style?.let {
+ return it != initialL2R2Style
+ }
+
+ return false
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/data/AppDatabase.kt b/app/src/main/java/de/langerhans/odintools/data/AppDatabase.kt
new file mode 100644
index 0000000..341f65e
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/data/AppDatabase.kt
@@ -0,0 +1,12 @@
+package de.langerhans.odintools.data
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [AppOverrideEntity::class],
+ version = 1
+)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun appOverrideDao(): AppOverrideDao
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/data/AppOverrideDao.kt b/app/src/main/java/de/langerhans/odintools/data/AppOverrideDao.kt
new file mode 100644
index 0000000..3149b94
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/data/AppOverrideDao.kt
@@ -0,0 +1,20 @@
+package de.langerhans.odintools.data
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AppOverrideDao {
+
+ @Query("SELECT * FROM appoverride")
+ fun getAll(): Flow>
+
+ @Query("SELECT * FROM appoverride WHERE packageName = :packageName")
+ fun getForPackage(packageName: String): AppOverrideEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun save(override: AppOverrideEntity)
+
+ @Query("DELETE FROM appoverride WHERE packageName = :packageName")
+ fun deleteByPackageName(packageName: String)
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/data/AppOverrideEntity.kt b/app/src/main/java/de/langerhans/odintools/data/AppOverrideEntity.kt
new file mode 100644
index 0000000..c90b302
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/data/AppOverrideEntity.kt
@@ -0,0 +1,12 @@
+package de.langerhans.odintools.data
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "appoverride")
+data class AppOverrideEntity(
+ @PrimaryKey
+ val packageName: String,
+ val controllerStyle: String?,
+ val l2R2Style: String?
+)
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/data/DatabaseModule.kt b/app/src/main/java/de/langerhans/odintools/data/DatabaseModule.kt
new file mode 100644
index 0000000..79d9498
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/data/DatabaseModule.kt
@@ -0,0 +1,28 @@
+package de.langerhans.odintools.data
+
+import android.content.Context
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+class DatabaseModule {
+
+ @Provides
+ fun provideAppOverrideDao(db: AppDatabase): AppOverrideDao {
+ return db.appOverrideDao()
+ }
+
+ @Provides
+ @Singleton
+ fun provideAppDatabase(@ApplicationContext appContext: Context): AppDatabase {
+ return Room.databaseBuilder(
+ appContext, AppDatabase::class.java, "app"
+ ).build()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/data/SharedPrefsRepo.kt b/app/src/main/java/de/langerhans/odintools/data/SharedPrefsRepo.kt
new file mode 100644
index 0000000..891cd47
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/data/SharedPrefsRepo.kt
@@ -0,0 +1,54 @@
+package de.langerhans.odintools.data
+
+import android.content.Context
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+class SharedPrefsRepo @Inject constructor(
+ @ApplicationContext context: Context
+) {
+
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ var disabledControllerStyle
+ get() = prefs.getString(KEY_DISABLED_CONTROLLER_STYLE, null)
+ set(value) = prefs.edit().putString(KEY_DISABLED_CONTROLLER_STYLE, value).apply()
+
+ var disabledL2r2Style
+ get() = prefs.getString(KEY_DISABLED_L2R2_STYLE, null)
+ set(value) = prefs.edit().putString(KEY_DISABLED_L2R2_STYLE, value).apply()
+
+ var saturationOverride
+ get() = prefs.getFloat(KEY_SATURATION_OVERRIDE, 1.0f)
+ set(value) = prefs.edit().putFloat(KEY_SATURATION_OVERRIDE, value).apply()
+
+ var appOverridesEnabled
+ get() = prefs.getBoolean(KEY_APP_OVERRIDE_ENABLED, true)
+ set(value) = prefs.edit().putBoolean(KEY_APP_OVERRIDE_ENABLED, value).apply()
+
+ private var appOverrideEnabledListener: OnSharedPreferenceChangeListener? = null
+
+ fun observeAppOverrideEnabledState(onChange: (newState: Boolean) -> Unit) {
+ appOverrideEnabledListener = OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_APP_OVERRIDE_ENABLED) {
+ onChange(appOverridesEnabled)
+ }
+ }
+ prefs.registerOnSharedPreferenceChangeListener(appOverrideEnabledListener)
+ }
+
+ fun removeAppOverrideEnabledObserver() {
+ prefs.unregisterOnSharedPreferenceChangeListener(appOverrideEnabledListener)
+ appOverrideEnabledListener = null
+ }
+
+ companion object {
+ private const val PREFS_NAME = "odintools"
+
+ private const val KEY_DISABLED_CONTROLLER_STYLE = "disabled_controller_style"
+ private const val KEY_DISABLED_L2R2_STYLE = "disabled_l2r2_style"
+ private const val KEY_SATURATION_OVERRIDE = "saturation_override"
+ private const val KEY_APP_OVERRIDE_ENABLED = "app_override_enabled"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/main/MainActivity.kt b/app/src/main/java/de/langerhans/odintools/main/MainActivity.kt
new file mode 100644
index 0000000..c14d76a
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/main/MainActivity.kt
@@ -0,0 +1,148 @@
+package de.langerhans.odintools.main
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import dagger.hilt.android.AndroidEntryPoint
+import de.langerhans.odintools.R
+import de.langerhans.odintools.appsettings.AppOverrideListScreen
+import de.langerhans.odintools.appsettings.AppOverridesScreen
+import de.langerhans.odintools.ui.composables.*
+import de.langerhans.odintools.ui.theme.OdinToolsTheme
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ OdinToolsTheme {
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = "settings") {
+ composable("settings") {
+ SettingsScreen { navController.navigate("override/list") }
+ }
+ composable("override/list") {
+ AppOverrideListScreen { navController.navigate("override/$it") }
+ }
+ composable("override/{packageName}") {
+ AppOverridesScreen {
+ navController.popBackStack()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SettingsScreen(
+ viewModel: MainViewModel = hiltViewModel(),
+ navigateToOverrideList: () -> Unit
+) {
+ val uiState: MainUiModel by viewModel.uiState.collectAsState()
+
+ if (uiState.showPServerNotAvailableDialog) {
+ PServerNotAvailableDialog()
+ } else if (uiState.showNotAnOdinDialog) {
+ NotAnOdinDialog { viewModel.incompatibleDeviceDialogDismissed() }
+ }
+
+ if (uiState.showControllerStyleDialog) {
+ CheckBoxDialogPreference(items = viewModel.controllerStyleOptions, onCancel = {
+ viewModel.hideControllerStylePreference()
+ }) {
+ viewModel.updateControllerStyles(it)
+ viewModel.hideControllerStylePreference()
+ }
+ }
+
+ if (uiState.showL2r2StyleDialog) {
+ CheckBoxDialogPreference(items = viewModel.l2r2StyleOptions, onCancel = {
+ viewModel.hideL2r2StylePreference()
+ }) {
+ viewModel.updateL2r2Styles(it)
+ viewModel.hideL2r2StylePreference()
+ }
+ }
+
+ if (uiState.showSaturationDialog) {
+ SaturationPreferenceDialog(
+ initialValue = uiState.currentSaturation,
+ onCancel = { viewModel.saturationDialogDismissed() },
+ onSave = { viewModel.saveSaturation(it) }
+ )
+ }
+
+ Scaffold(topBar = {OdinTopAppBar(deviceVersion = uiState.deviceVersion)}) { contentPadding ->
+ Column(
+ modifier = Modifier
+ .padding(contentPadding)
+ .padding(end = 8.dp) // Extra padding cause of GameAssist bar overlay
+ .verticalScroll(rememberScrollState())
+ ) {
+ SettingsHeader(R.string.appOverrides)
+ SwitchableTriggerPreference(
+ icon = R.drawable.ic_app_settings,
+ title = R.string.appOverrides,
+ description = R.string.appOverridesDescription,
+ state = uiState.appOverridesEnabled,
+ onClick = navigateToOverrideList
+ ) { newValue ->
+ viewModel.appOverridesEnabled(newValue)
+ }
+ SettingsHeader(R.string.quickstettings)
+ TriggerPreference(
+ icon = R.drawable.ic_controllerstyle,
+ title = R.string.controllerStyle,
+ description = R.string.controllerStyleDesc
+ ) {
+ viewModel.showControllerStylePreference()
+ }
+ TriggerPreference(
+ icon = R.drawable.ic_sliders, title = R.string.l2r2mode, description = R.string.l2r2modeDesc
+ ) {
+ viewModel.showL2r2StylePreference()
+ }
+ SettingsHeader(R.string.buttons)
+ SwitchPreference(
+ icon = R.drawable.ic_home,
+ title = R.string.doubleHomeTitle,
+ description = R.string.doubleHomeDescription,
+ state = uiState.singleHomeEnabled
+ ) {
+ viewModel.updateSingleHomePreference(it)
+ }
+ SettingsHeader(name = R.string.display)
+ TriggerPreference(
+ icon = R.drawable.ic_palette,
+ title = R.string.saturation,
+ description = R.string.saturationDescription
+ ) {
+ viewModel.saturationClicked()
+ }
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/de/langerhans/odintools/main/MainUiModel.kt b/app/src/main/java/de/langerhans/odintools/main/MainUiModel.kt
new file mode 100644
index 0000000..36e8615
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/main/MainUiModel.kt
@@ -0,0 +1,29 @@
+package de.langerhans.odintools.main
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+data class MainUiModel(
+ val deviceVersion: String = "",
+ val showNotAnOdinDialog: Boolean = false,
+ val showPServerNotAvailableDialog: Boolean = false,
+
+ val singleHomeEnabled: Boolean = false,
+ val appOverridesEnabled: Boolean = true,
+
+ val showControllerStyleDialog: Boolean = false,
+ val showL2r2StyleDialog: Boolean = false,
+
+ val showSaturationDialog: Boolean = false,
+ val currentSaturation: Float = 1.0f
+)
+
+class CheckboxPreferenceUiModel(
+ val key: String,
+ @StringRes val text: Int,
+ initialChecked: Boolean = false
+) {
+ var checked by mutableStateOf(initialChecked)
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/main/MainViewModel.kt b/app/src/main/java/de/langerhans/odintools/main/MainViewModel.kt
new file mode 100644
index 0000000..bdc2187
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/main/MainViewModel.kt
@@ -0,0 +1,146 @@
+package de.langerhans.odintools.main
+
+import androidx.compose.runtime.toMutableStateList
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.langerhans.odintools.R
+import de.langerhans.odintools.data.SharedPrefsRepo
+import de.langerhans.odintools.models.ControllerStyle.*
+import de.langerhans.odintools.models.L2R2Style.*
+import de.langerhans.odintools.tools.DeviceUtils
+import de.langerhans.odintools.tools.ShellExecutor
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ deviceUtils: DeviceUtils,
+ private val executor: ShellExecutor,
+ private val prefs: SharedPrefsRepo
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(MainUiModel())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var _controllerStyleOptions = getCurrentControllerStyles().toMutableStateList()
+ val controllerStyleOptions: List
+ get() = _controllerStyleOptions
+
+ private var _l2r2StyleOptions = getCurrentL2r2Styles().toMutableStateList()
+ val l2r2StyleOptions: List
+ get() = _l2r2StyleOptions
+
+ init {
+ executor.enableA11yService()
+ executor.grantAllAppsPermission()
+
+ val isOdin2 = deviceUtils.isOdin2()
+ val preventHomePressSetting = executor.getBooleanSystemSetting("prevent_press_home_accidentally", true)
+
+ _uiState.update { _ ->
+ MainUiModel(
+ deviceVersion = deviceUtils.getDeviceVersion(),
+ showNotAnOdinDialog = !isOdin2,
+ singleHomeEnabled = !preventHomePressSetting,
+ showPServerNotAvailableDialog = !deviceUtils.isPServerAvailable()
+ )
+ }
+ }
+
+ fun incompatibleDeviceDialogDismissed() {
+ _uiState.update { current ->
+ current.copy(showNotAnOdinDialog = false)
+ }
+ }
+
+ fun updateSingleHomePreference(newValue: Boolean) {
+ // Invert here as prevent == double press
+ executor.setBooleanSystemSetting("prevent_press_home_accidentally", !newValue)
+
+ _uiState.update { current ->
+ current.copy(singleHomeEnabled = newValue)
+ }
+ }
+
+ fun showControllerStylePreference() {
+ _controllerStyleOptions = getCurrentControllerStyles().toMutableStateList()
+ _uiState.update { current ->
+ current.copy(showControllerStyleDialog = true)
+ }
+ }
+
+ fun hideControllerStylePreference() {
+ _uiState.update { current ->
+ current.copy(showControllerStyleDialog = false)
+ }
+ }
+
+ private fun getCurrentControllerStyles(): List {
+ val disabled = prefs.disabledControllerStyle
+ return listOf(
+ CheckboxPreferenceUiModel(Xbox.id, R.string.xbox, disabled != Xbox.id),
+ CheckboxPreferenceUiModel(Odin.id, R.string.odin, disabled != Odin.id),
+ CheckboxPreferenceUiModel(Disconnect.id, R.string.disconnect, disabled != Disconnect.id)
+ )
+ }
+
+ fun updateControllerStyles(models: List) {
+ prefs.disabledControllerStyle = models.find { it.checked.not() }?.key
+ }
+
+ fun showL2r2StylePreference() {
+ _l2r2StyleOptions = getCurrentL2r2Styles().toMutableStateList()
+ _uiState.update { current ->
+ current.copy(showL2r2StyleDialog = true)
+ }
+ }
+
+ fun hideL2r2StylePreference() {
+ _uiState.update { current ->
+ current.copy(showL2r2StyleDialog = false)
+ }
+ }
+
+ private fun getCurrentL2r2Styles(): List {
+ val disabled = prefs.disabledL2r2Style
+ return listOf(
+ CheckboxPreferenceUiModel(Analog.id, R.string.analog, disabled != Analog.id),
+ CheckboxPreferenceUiModel(Digital.id, R.string.digitial, disabled != Digital.id),
+ CheckboxPreferenceUiModel(Both.id, R.string.both, disabled != Both.id)
+ )
+ }
+
+ fun updateL2r2Styles(models: List) {
+ prefs.disabledL2r2Style = models.find { it.checked.not() }?.key
+ }
+
+ fun saturationClicked() {
+ _uiState.update {
+ it.copy(showSaturationDialog = true, currentSaturation = prefs.saturationOverride)
+ }
+ }
+
+ fun saturationDialogDismissed() {
+ _uiState.update {
+ it.copy(showSaturationDialog = false)
+ }
+ }
+
+ fun saveSaturation(newValue: Float) {
+ prefs.saturationOverride = newValue
+ executor.setSfSaturation(newValue)
+ _uiState.update {
+ it.copy(showSaturationDialog = false)
+ }
+ }
+
+ fun appOverridesEnabled(newValue: Boolean) {
+ prefs.appOverridesEnabled = newValue
+ _uiState.update {
+ it.copy(appOverridesEnabled = newValue)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/models/ConstantModels.kt b/app/src/main/java/de/langerhans/odintools/models/ConstantModels.kt
new file mode 100644
index 0000000..46ee79d
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/models/ConstantModels.kt
@@ -0,0 +1,9 @@
+package de.langerhans.odintools.models
+
+import androidx.annotation.StringRes
+import de.langerhans.odintools.R
+
+data object NoChange {
+ const val KEY = "odintools#no_change"
+ @StringRes val textRes = R.string.noChange
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/models/ControllerStyle.kt b/app/src/main/java/de/langerhans/odintools/models/ControllerStyle.kt
new file mode 100644
index 0000000..e231d18
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/models/ControllerStyle.kt
@@ -0,0 +1,45 @@
+package de.langerhans.odintools.models
+
+import androidx.annotation.StringRes
+import de.langerhans.odintools.R
+import de.langerhans.odintools.tools.ShellExecutor
+
+sealed class ControllerStyle(
+ val id: String,
+ private val tempAbxyLayout: Int,
+ private val noCreateGamepadLayout: Int,
+ private val flipButtonLayout: Int,
+ @StringRes val textRes: Int
+) {
+ data object Xbox : ControllerStyle("xbox", 0, 0, 1, R.string.xbox)
+ data object Odin : ControllerStyle("odin", 1, 0, 0, R.string.odin)
+ data object Disconnect : ControllerStyle("disconnect",2, 1, 0, R.string.disconnect)
+ data object Unknown : ControllerStyle("unknown", -1, -1, -1, R.string.unknown)
+
+ fun enable(executor: ShellExecutor) {
+ if (this != Unknown) {
+ executor.setSystemSetting(TEMP_ABXY_LAYOUT_MODE, tempAbxyLayout)
+ executor.setSystemSetting(NO_CREATE_GAMEPAD_BUTTON_LAYOUT, noCreateGamepadLayout)
+ executor.setSystemSetting(FLIP_BUTTON_LAYOUT, flipButtonLayout)
+ }
+ }
+
+ companion object {
+ private const val TEMP_ABXY_LAYOUT_MODE = "temp_abxy_layout_mode"
+ private const val NO_CREATE_GAMEPAD_BUTTON_LAYOUT = "no_create_gamepad_button_layout"
+ private const val FLIP_BUTTON_LAYOUT = "flip_button_layout"
+
+ fun getStyle(executor: ShellExecutor): ControllerStyle {
+ return if (executor.getSystemSetting(NO_CREATE_GAMEPAD_BUTTON_LAYOUT, 0) == 1) Disconnect
+ else if (executor.getSystemSetting(FLIP_BUTTON_LAYOUT, 0) == 1) Xbox
+ else Odin
+ }
+
+ fun getById(id: String?) = when(id) {
+ Xbox.id -> Xbox
+ Odin.id -> Odin
+ Disconnect.id -> Disconnect
+ else -> Unknown
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/models/L2R2Style.kt b/app/src/main/java/de/langerhans/odintools/models/L2R2Style.kt
new file mode 100644
index 0000000..cf5f2a5
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/models/L2R2Style.kt
@@ -0,0 +1,42 @@
+package de.langerhans.odintools.models
+
+import androidx.annotation.StringRes
+import de.langerhans.odintools.R
+import de.langerhans.odintools.tiles.L2R2TileService
+import de.langerhans.odintools.tools.ShellExecutor
+
+sealed class L2R2Style(
+ val id: String,
+ val settingsValue: Int,
+ @StringRes val textRes: Int
+) {
+ data object Analog : L2R2Style("analog", 0, R.string.analog)
+ data object Digital : L2R2Style("digital", 1, R.string.digitial)
+ data object Both : L2R2Style("both", 2, R.string.both)
+ data object Unknown : L2R2Style("unknown", -1, R.string.unknown)
+
+ fun enable(executor: ShellExecutor) {
+ if (this != Unknown) {
+ executor.setSystemSetting(TRIGGER_INPUT_MODE, settingsValue)
+ }
+ }
+
+ companion object {
+ private const val TRIGGER_INPUT_MODE = "trigger_input_mode"
+
+ fun getStyle(executor: ShellExecutor) =
+ when (executor.getSystemSetting(TRIGGER_INPUT_MODE, Analog.settingsValue)) {
+ Analog.settingsValue -> Analog
+ Digital.settingsValue -> Digital
+ Both.settingsValue -> Both
+ else -> Unknown
+ }
+
+ fun getById(id: String?) = when(id) {
+ Analog.id -> Analog
+ Digital.id -> Digital
+ Both.id -> Both
+ else -> Unknown
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/service/ForegroundAppWatcherService.kt b/app/src/main/java/de/langerhans/odintools/service/ForegroundAppWatcherService.kt
new file mode 100644
index 0000000..171bbe6
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/service/ForegroundAppWatcherService.kt
@@ -0,0 +1,157 @@
+package de.langerhans.odintools.service
+
+import android.accessibilityservice.AccessibilityService
+import android.database.ContentObserver
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.view.accessibility.AccessibilityEvent
+import dagger.hilt.android.AndroidEntryPoint
+import de.langerhans.odintools.data.AppOverrideDao
+import de.langerhans.odintools.data.AppOverrideEntity
+import de.langerhans.odintools.data.SharedPrefsRepo
+import de.langerhans.odintools.models.ControllerStyle
+import de.langerhans.odintools.models.ControllerStyle.Unknown
+import de.langerhans.odintools.models.L2R2Style
+import de.langerhans.odintools.tools.ShellExecutor
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ForegroundAppWatcherService @Inject constructor(): AccessibilityService() {
+
+ @Inject
+ lateinit var appOverrideDao: AppOverrideDao
+
+ @Inject
+ lateinit var shellExecutor: ShellExecutor
+
+ @Inject
+ lateinit var sharedPrefsRepo: SharedPrefsRepo
+
+ private val job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+
+ private var currentForegroundPackage: CharSequence = ""
+ private lateinit var overrides: List
+ private var overridesEnabled = true
+
+ private var hasSetOverride = false
+ private var savedControllerStyle: ControllerStyle? = null
+ private var savedL2R2Style: L2R2Style? = null
+
+ private var currentIme = ""
+ private val imeObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
+ override fun onChange(selfChange: Boolean) {
+ currentIme = shellExecutor
+ .executeAsRoot("settings get secure default_input_method")
+ .getOrDefault("") ?: ""
+ }
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent) {
+ if (shouldIgnore(event)) return
+
+ currentForegroundPackage = event.packageName
+
+ overrides.find { it.packageName == currentForegroundPackage }?.let { override ->
+ applyOverride(override)
+ } ?: run {
+ resetOverrides()
+ }
+ }
+
+ private fun shouldIgnore(event: AccessibilityEvent): Boolean {
+ if (overridesEnabled.not()) return true // User disabled overrides globally
+ if (::overrides.isInitialized.not()) return true // Got an event before the DB was returning data
+ if (ignoredPackages.contains(event.packageName)) return true // Ignore some system packages
+ if (event.packageName == currentForegroundPackage) return true // No action on duplicate events
+ if (currentIme.contains(event.packageName)) return true // Ignore keyboards popping up
+
+ return false // All good, process event
+ }
+
+ private fun applyOverride(override: AppOverrideEntity) {
+ // This check makes sure that we don't override the "defaults" when switching between apps with overrides
+ if (!hasSetOverride) {
+ savedControllerStyle = ControllerStyle.getStyle(shellExecutor)
+ savedL2R2Style = L2R2Style.getStyle(shellExecutor)
+ }
+
+ ControllerStyle.getById(override.controllerStyle).takeIf {
+ it != Unknown
+ }?.enable(shellExecutor) ?: run {
+ // Reset to default if we switch between override and NoChange app
+ savedControllerStyle?.enable(shellExecutor)
+ }
+
+ L2R2Style.getById(override.l2R2Style).takeIf {
+ it != L2R2Style.Unknown
+ }?.enable(shellExecutor) ?: run {
+ // Reset to default if we switch between override and NoChange app
+ savedL2R2Style?.enable(shellExecutor)
+ }
+
+ hasSetOverride = true
+ }
+
+ private fun resetOverrides() {
+ if (!hasSetOverride) return
+
+ savedControllerStyle?.enable(shellExecutor)
+ savedControllerStyle = null
+
+ savedL2R2Style?.enable(shellExecutor)
+ savedL2R2Style = null
+
+ hasSetOverride = false
+ }
+
+ override fun onInterrupt() {
+ // Nothing here
+ }
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+
+ currentIme = shellExecutor
+ .executeAsRoot("settings get secure default_input_method")
+ .getOrDefault("") ?: ""
+ contentResolver.registerContentObserver(
+ Settings.Secure.getUriFor(Settings.Secure.DEFAULT_INPUT_METHOD),
+ false,
+ imeObserver
+ )
+
+ overridesEnabled = sharedPrefsRepo.appOverridesEnabled
+ sharedPrefsRepo.observeAppOverrideEnabledState { overridesEnabled = it }
+
+ scope.launch {
+ appOverrideDao.getAll()
+ .flowOn(Dispatchers.IO)
+ .collect { overrides ->
+ this@ForegroundAppWatcherService.overrides = overrides
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ job.cancel()
+ contentResolver.unregisterContentObserver(imeObserver)
+ sharedPrefsRepo.removeAppOverrideEnabledObserver()
+ }
+
+ companion object {
+ private val ignoredPackages = listOf(
+ "com.android.launcher3",
+ "com.odin2.gameassistant",
+ "com.android.systemui",
+ "android"
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/tiles/ControllerTileService.kt b/app/src/main/java/de/langerhans/odintools/tiles/ControllerTileService.kt
new file mode 100644
index 0000000..51e3b3c
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/tiles/ControllerTileService.kt
@@ -0,0 +1,62 @@
+package de.langerhans.odintools.tiles
+
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import dagger.hilt.android.AndroidEntryPoint
+import de.langerhans.odintools.R
+import de.langerhans.odintools.models.ControllerStyle
+import de.langerhans.odintools.models.ControllerStyle.*
+import de.langerhans.odintools.tools.DeviceUtils
+import de.langerhans.odintools.data.SharedPrefsRepo
+import de.langerhans.odintools.tools.ShellExecutor
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ControllerTileService : TileService() {
+
+ @Inject
+ lateinit var executor: ShellExecutor
+
+ @Inject
+ lateinit var deviceUtils: DeviceUtils
+
+ @Inject
+ lateinit var prefs: SharedPrefsRepo
+
+ private var disabledStyle: ControllerStyle? = null
+
+ override fun onStartListening() {
+ super.onStartListening()
+ if (!deviceUtils.isPServerAvailable()) {
+ qsTile.state = Tile.STATE_UNAVAILABLE
+ qsTile.subtitle = getString(R.string.unknown)
+ qsTile.updateTile()
+ return
+ }
+
+ val currentStyle = ControllerStyle.getStyle(executor)
+ disabledStyle = prefs.disabledControllerStyle?.let {
+ ControllerStyle.getById(it)
+ }
+
+ qsTile.state = Tile.STATE_ACTIVE
+ qsTile.subtitle = getString(currentStyle.textRes)
+ qsTile.updateTile()
+ }
+
+ override fun onClick() {
+ super.onClick()
+
+ val current = ControllerStyle.getStyle(executor)
+ val newStyle = when (current) {
+ Xbox -> if (disabledStyle == Odin) Disconnect else Odin
+ Odin -> if (disabledStyle == Disconnect) Xbox else Disconnect
+ Disconnect, Unknown -> if (disabledStyle == Xbox) Odin else Xbox
+ }
+ newStyle.enable(executor)
+
+ qsTile.state = Tile.STATE_ACTIVE
+ qsTile.subtitle = getString(newStyle.textRes)
+ qsTile.updateTile()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/tiles/L2R2TileService.kt b/app/src/main/java/de/langerhans/odintools/tiles/L2R2TileService.kt
new file mode 100644
index 0000000..ff9b02f
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/tiles/L2R2TileService.kt
@@ -0,0 +1,62 @@
+package de.langerhans.odintools.tiles
+
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import dagger.hilt.android.AndroidEntryPoint
+import de.langerhans.odintools.R
+import de.langerhans.odintools.models.L2R2Style
+import de.langerhans.odintools.models.L2R2Style.*
+import de.langerhans.odintools.tools.DeviceUtils
+import de.langerhans.odintools.data.SharedPrefsRepo
+import de.langerhans.odintools.tools.ShellExecutor
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class L2R2TileService : TileService() {
+
+ @Inject
+ lateinit var executor: ShellExecutor
+
+ @Inject
+ lateinit var deviceUtils: DeviceUtils
+
+ @Inject
+ lateinit var prefs: SharedPrefsRepo
+
+ private var disabledStyle: L2R2Style? = null
+
+ override fun onStartListening() {
+ super.onStartListening()
+ if (!deviceUtils.isPServerAvailable()) {
+ qsTile.state = Tile.STATE_UNAVAILABLE
+ qsTile.subtitle = getString(R.string.unknown)
+ qsTile.updateTile()
+ return
+ }
+
+ val currentStyle = L2R2Style.getStyle(executor)
+ disabledStyle = prefs.disabledL2r2Style?.let {
+ L2R2Style.getById(it)
+ }
+
+ qsTile.state = Tile.STATE_ACTIVE
+ qsTile.subtitle = getString(currentStyle.textRes)
+ qsTile.updateTile()
+ }
+
+ override fun onClick() {
+ super.onClick()
+
+ val current = L2R2Style.getStyle(executor)
+ val newStyle = when (current) {
+ is Analog -> if (disabledStyle == Digital) Both else Digital
+ is Digital -> if (disabledStyle == Both) Analog else Both
+ is Both, is Unknown -> if (disabledStyle == Analog) Digital else Analog
+ }
+ newStyle.enable(executor)
+
+ qsTile.state = Tile.STATE_ACTIVE
+ qsTile.subtitle = getString(newStyle.textRes)
+ qsTile.updateTile()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/tools/BootReceiver.kt b/app/src/main/java/de/langerhans/odintools/tools/BootReceiver.kt
new file mode 100644
index 0000000..e123fc9
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/tools/BootReceiver.kt
@@ -0,0 +1,29 @@
+package de.langerhans.odintools.tools
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import de.langerhans.odintools.data.SharedPrefsRepo
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class BootReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var sharedPrefsRepo: SharedPrefsRepo
+
+ @Inject
+ lateinit var executor: ShellExecutor
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) {
+ return
+ }
+
+ val saturation = sharedPrefsRepo.saturationOverride
+ if (saturation != 1.0f) {
+ executor.setSfSaturation(saturation)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/tools/DeviceUtils.kt b/app/src/main/java/de/langerhans/odintools/tools/DeviceUtils.kt
new file mode 100644
index 0000000..c506e4c
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/tools/DeviceUtils.kt
@@ -0,0 +1,15 @@
+package de.langerhans.odintools.tools
+
+import javax.inject.Inject
+
+class DeviceUtils @Inject constructor(
+ private val shellExecutor: ShellExecutor
+) {
+ fun isOdin2() = shellExecutor.executeAsRoot("getprop ro.vendor.retro.name").map { it == "Q9" }
+ .getOrDefault(false)
+
+ fun isPServerAvailable() = shellExecutor.pServerAvailable
+
+ fun getDeviceVersion() = shellExecutor.executeAsRoot("getprop ro.build.odin2.ota.version").map { it ?: "" }
+ .getOrDefault("")
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/tools/ShellExecutor.kt b/app/src/main/java/de/langerhans/odintools/tools/ShellExecutor.kt
new file mode 100644
index 0000000..6982e92
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/tools/ShellExecutor.kt
@@ -0,0 +1,87 @@
+package de.langerhans.odintools.tools
+
+import android.annotation.SuppressLint
+import android.os.IBinder
+import android.os.Parcel
+import java.nio.charset.Charset
+import javax.inject.Inject
+
+@SuppressLint("DiscouragedPrivateApi", "PrivateApi") // :kekw:
+class ShellExecutor @Inject constructor() {
+
+ private val binder: IBinder?
+ var pServerAvailable: Boolean = false
+ private set
+
+ init {
+ binder = runCatching {
+ val serviceManager = Class.forName("android.os.ServiceManager")
+ val getService = serviceManager.getDeclaredMethod("getService", String::class.java)
+ val binder = getService.invoke(serviceManager, "PServerBinder") as IBinder
+ pServerAvailable = true
+ binder
+ }.getOrDefault(null)
+ }
+
+ fun executeAsRoot(cmd: String): Result {
+ if (binder == null) return Result.failure(IllegalStateException("PServer not available!"))
+
+ val data = Parcel.obtain()
+ val reply = Parcel.obtain()
+ data.writeStringArray(arrayOf(cmd, "1"))
+ runCatching { binder!!.transact(0, data, reply, 0) }
+ .getOrElse {
+ return Result.failure(it)
+ }
+ val result = reply.createByteArray()?.toString(Charset.defaultCharset())?.trim()
+ data.recycle()
+ reply.recycle()
+ return Result.success(result)
+ }
+
+ fun getSystemSetting(setting: String, defaultValue: Int): Int {
+ return executeAsRoot("settings get system $setting")
+ .map { if (it == null || it == "null") defaultValue else it.toInt() }
+ .getOrDefault(defaultValue)
+ }
+
+ fun setSystemSetting(setting: String, value: Int) {
+ executeAsRoot("settings put system $setting $value")
+ }
+
+ fun setBooleanSystemSetting(setting: String, value: Boolean) {
+ setSystemSetting(setting, if (value) 1 else 0)
+ }
+
+ fun getBooleanSystemSetting(setting: String, defaultValue: Boolean): Boolean {
+ return executeAsRoot("settings get system $setting")
+ .map { if (it == null) defaultValue else it == "1" }
+ .getOrDefault(defaultValue)
+ }
+
+ fun enableA11yService() {
+ val currentServices =
+ executeAsRoot("settings get secure enabled_accessibility_services")
+ .map { it ?: "" }
+ .getOrDefault("")
+
+ if (currentServices.contains("de.langerhans.odintools")) return
+
+ executeAsRoot(
+ "settings put secure enabled_accessibility_services $PACKAGE/$PACKAGE.service.ForegroundAppWatcherService:$currentServices"
+ .trimEnd(':')
+ )
+ }
+
+ fun setSfSaturation(saturation: Float) {
+ executeAsRoot("service call SurfaceFlinger 1022 f ${String.format("%.1f", saturation)}")
+ }
+
+ fun grantAllAppsPermission() {
+ executeAsRoot("pm grant $PACKAGE android.permission.QUERY_ALL_PACKAGES")
+ }
+
+ companion object {
+ private const val PACKAGE = "de.langerhans.odintools"
+ }
+}
diff --git a/app/src/main/java/de/langerhans/odintools/ui/composables/Dialogs.kt b/app/src/main/java/de/langerhans/odintools/ui/composables/Dialogs.kt
new file mode 100644
index 0000000..9c3fbe0
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/ui/composables/Dialogs.kt
@@ -0,0 +1,83 @@
+package de.langerhans.odintools.ui.composables
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import de.langerhans.odintools.R
+import kotlin.system.exitProcess
+
+@Composable
+fun NotAnOdinDialog(
+ onAccept: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ DialogButton(text = stringResource(id = R.string.accept)) {
+ onAccept()
+ }
+ },
+ dismissButton = {
+ DialogButton(text = stringResource(id = R.string.close)) {
+ exitProcess(0)
+ }
+ },
+ title = {
+ Text(text = stringResource(id = R.string.warning))
+ },
+ text = {
+ Text(text = stringResource(id = R.string.incompatibleDevice))
+ }
+ )
+}
+
+@Composable
+fun PServerNotAvailableDialog() {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ DialogButton(text = stringResource(id = R.string.closeButSad)) {
+ exitProcess(0)
+ }
+ },
+ title = {
+ Text(text = stringResource(id = R.string.warning))
+ },
+ text = {
+ Text(text = stringResource(id = R.string.pServerNotAvailable))
+ }
+ )
+}
+
+@Composable
+fun DialogButton(
+ text: String,
+ onClick: () -> Unit
+) {
+ TextButton(
+ onClick = onClick
+ ) {
+ Text(text = text)
+ }
+}
+
+@Composable
+fun DeleteConfirmDialog(
+ onDelete: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ DialogButton(text = stringResource(id = R.string.delete), onClick = onDelete)
+ },
+ dismissButton = {
+ DialogButton(text = stringResource(id = R.string.cancel), onClick = onDismiss)
+ },
+ text = {
+ Text(text = stringResource(id = R.string.deleteConfirm))
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/ui/composables/LargeDropDownMenu.kt b/app/src/main/java/de/langerhans/odintools/ui/composables/LargeDropDownMenu.kt
new file mode 100644
index 0000000..6ebf7e8
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/ui/composables/LargeDropDownMenu.kt
@@ -0,0 +1,138 @@
+package de.langerhans.odintools.ui.composables
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+
+/**
+ * Taken and adapted from https://proandroiddev.com/improving-the-compose-dropdownmenu-88469b1ef34
+ */
+@Composable
+fun LargeDropdownMenu(
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ notSetLabel: String? = null,
+ items: List,
+ selectedIndex: Int = -1,
+ onItemSelected: (index: Int, item: T) -> Unit,
+ selectedItemToString: (T) -> String = { it.toString() },
+ drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item, selected, itemEnabled, onClick ->
+ LargeDropdownMenuItem(
+ text = selectedItemToString(item),
+ selected = selected,
+ enabled = itemEnabled,
+ onClick = onClick,
+ )
+ },
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Box(modifier = modifier.height(IntrinsicSize.Min).width(IntrinsicSize.Min)) {
+ OutlinedTextField(
+ value = items.getOrNull(selectedIndex)?.let { selectedItemToString(it) } ?: "",
+ enabled = enabled,
+ trailingIcon = {
+ val icon = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown
+ Icon(imageVector = icon, "")
+ },
+ onValueChange = { },
+ readOnly = true,
+ )
+
+ // Transparent clickable surface on top of OutlinedTextField
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(MaterialTheme.shapes.extraSmall)
+ .clickable(enabled = enabled) { expanded = true },
+ color = Color.Transparent,
+ ) {}
+ }
+
+ if (expanded) {
+ Dialog(
+ onDismissRequest = { expanded = false },
+ ) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ val listState = rememberLazyListState()
+ if (selectedIndex > -1) {
+ LaunchedEffect("ScrollToSelected") {
+ listState.scrollToItem(index = selectedIndex)
+ }
+ }
+
+ LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) {
+ if (notSetLabel != null) {
+ item {
+ LargeDropdownMenuItem(
+ text = notSetLabel,
+ selected = false,
+ enabled = false,
+ onClick = { },
+ )
+ }
+ }
+ itemsIndexed(items) { index, item ->
+ val selectedItem = index == selectedIndex
+ drawItem(
+ item,
+ selectedItem,
+ true
+ ) {
+ onItemSelected(index, item)
+ expanded = false
+ }
+
+ if (index < items.lastIndex) {
+ Divider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun LargeDropdownMenuItem(
+ text: String,
+ selected: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ val contentColor = when {
+ !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_DISABLED)
+ selected -> MaterialTheme.colorScheme.primary.copy(alpha = ALPHA_FULL)
+ else -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_FULL)
+ }
+
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ Box(modifier = Modifier
+ .clickable(enabled) { onClick() }
+ .fillMaxWidth()
+ .padding(16.dp)) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ }
+}
+
+const val ALPHA_DISABLED = 0.75f
+const val ALPHA_FULL = 1f
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/ui/composables/Preferences.kt b/app/src/main/java/de/langerhans/odintools/ui/composables/Preferences.kt
new file mode 100644
index 0000000..9823ec2
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/ui/composables/Preferences.kt
@@ -0,0 +1,239 @@
+package de.langerhans.odintools.ui.composables
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.langerhans.odintools.R
+import de.langerhans.odintools.main.CheckboxPreferenceUiModel
+
+@Composable
+fun SettingsHeader(@StringRes name: Int) {
+ Text(
+ text = stringResource(id = name),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, bottom = 2.dp, top = 8.dp)
+ )
+}
+
+@Composable
+fun PreferenceDescription(
+ @DrawableRes icon: Int,
+ @StringRes title: Int,
+ @StringRes description: Int,
+ modifier: Modifier = Modifier
+) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+ Image(painter = painterResource(id = icon), contentDescription = null)
+ Column(
+ modifier = Modifier.padding(start = 16.dp)
+ ) {
+ Text(
+ text = stringResource(id = title), modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Text(
+ text = stringResource(id = description), style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+}
+
+@Composable
+fun SwitchPreference(
+ @DrawableRes icon: Int,
+ @StringRes title: Int,
+ @StringRes description: Int,
+ state: Boolean,
+ onChange: (newValue: Boolean) -> Unit
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onChange(state.not())
+ }
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ PreferenceDescription(
+ icon = icon,
+ title = title,
+ description = description,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp)
+ )
+ Switch(
+ checked = state,
+ onCheckedChange = {
+ onChange(it)
+ },
+ )
+ }
+}
+
+@Composable
+fun SwitchableTriggerPreference(
+ @DrawableRes icon: Int,
+ @StringRes title: Int,
+ @StringRes description: Int,
+ state: Boolean,
+ onClick: () -> Unit,
+ onChange: (newValue: Boolean) -> Unit
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onClick()
+ }
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ PreferenceDescription(
+ icon = icon,
+ title = title,
+ description = description,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp)
+ )
+ Row(Modifier.height(IntrinsicSize.Min)) {
+ Divider(
+ modifier = Modifier
+ .width(17.dp)
+ .padding(end = 16.dp)
+ .fillMaxHeight()
+ )
+ Switch(
+ checked = state,
+ onCheckedChange = {
+ onChange(it)
+ },
+ )
+ }
+ }
+}
+
+@Composable
+fun TriggerPreference(
+ @DrawableRes icon: Int,
+ @StringRes title: Int,
+ @StringRes description: Int,
+ onClick: () -> Unit
+) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onClick()
+ }
+ .padding(horizontal = 16.dp, vertical = 8.dp)) {
+ PreferenceDescription(icon = icon, title = title, description = description)
+ }
+}
+
+@Composable
+fun CheckBoxDialogPreference(
+ items: List,
+ minSelected: Int = 2,
+ onCancel: () -> Unit,
+ onSave: (items: List) -> Unit
+) {
+ AlertDialog(onDismissRequest = {}, confirmButton = {
+ DialogButton(text = stringResource(id = R.string.save)) {
+ onSave(items)
+ }
+ }, dismissButton = {
+ DialogButton(text = stringResource(id = R.string.cancel), onCancel)
+ }, title = {
+ Text(text = stringResource(id = R.string.controllerStyle))
+ }, text = {
+ LazyColumn {
+ items(items = items, key = { item -> item.key }) { item ->
+ fun canChangeCheckbox(): Boolean {
+ return item.checked.not() || items.count { it.checked } == minSelected + 1
+ }
+
+ CheckboxDialogRow(
+ text = stringResource(id = item.text),
+ checked = item.checked,
+ enabled = canChangeCheckbox()
+ ) {
+ item.checked = it
+ }
+ }
+ }
+ })
+}
+
+@Composable
+fun CheckboxDialogRow(
+ text: String,
+ enabled: Boolean,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
+ if (enabled) onCheckedChange.invoke(!checked)
+ }
+ ) {
+ Text(text = text)
+ Spacer(modifier = Modifier.weight(1f))
+ Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
+ }
+}
+
+@Composable
+fun SaturationPreferenceDialog(
+ initialValue: Float,
+ onCancel: () -> Unit,
+ onSave: (newVal: Float) -> Unit
+) {
+ var userValue: Float by remember {
+ mutableFloatStateOf(initialValue)
+ }
+
+ AlertDialog(onDismissRequest = {}, confirmButton = {
+ DialogButton(text = stringResource(id = R.string.save)) {
+ onSave(userValue)
+ }
+ }, dismissButton = {
+ DialogButton(text = stringResource(id = R.string.cancel), onCancel)
+ }, title = {
+ Text(text = stringResource(id = R.string.saturation))
+ }, text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Slider(
+ value = userValue,
+ valueRange = 0f..2f,
+ steps = 19,
+ onValueChange = {
+ userValue = it
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ )
+ Text(
+ text = String.format("%.1f", userValue),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ })
+}
diff --git a/app/src/main/java/de/langerhans/odintools/ui/composables/TopAppBar.kt b/app/src/main/java/de/langerhans/odintools/ui/composables/TopAppBar.kt
new file mode 100644
index 0000000..ac5f06f
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/ui/composables/TopAppBar.kt
@@ -0,0 +1,45 @@
+package de.langerhans.odintools.ui.composables
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import de.langerhans.odintools.BuildConfig
+import de.langerhans.odintools.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OdinTopAppBar(
+ deviceVersion: String
+) = TopAppBar(title = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.appName),
+ style = MaterialTheme.typography.headlineMedium
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = stringResource(id = R.string.topBarVersions),
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.End,
+ modifier = Modifier.padding(end = 4.dp)
+ )
+ Text(
+ text = "$deviceVersion\n${BuildConfig.VERSION_NAME}",
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.End,
+ modifier = Modifier.padding(end = 16.dp)
+ )
+ }
+})
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/ui/theme/Theme.kt b/app/src/main/java/de/langerhans/odintools/ui/theme/Theme.kt
new file mode 100644
index 0000000..d705ba5
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/ui/theme/Theme.kt
@@ -0,0 +1,37 @@
+package de.langerhans.odintools.ui.theme
+
+import android.app.Activity
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+@Composable
+fun OdinToolsTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val context = LocalContext.current
+ val colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/de/langerhans/odintools/ui/theme/Type.kt b/app/src/main/java/de/langerhans/odintools/ui/theme/Type.kt
new file mode 100644
index 0000000..e92684b
--- /dev/null
+++ b/app/src/main/java/de/langerhans/odintools/ui/theme/Type.kt
@@ -0,0 +1,18 @@
+package de.langerhans.odintools.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+)
\ No newline at end of file
diff --git a/app/src/main/res/drawable-night/ic_app_settings.xml b/app/src/main/res/drawable-night/ic_app_settings.xml
new file mode 100644
index 0000000..e487ecb
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_app_settings.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable-night/ic_controllerstyle.xml b/app/src/main/res/drawable-night/ic_controllerstyle.xml
new file mode 100644
index 0000000..1bee04c
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_controllerstyle.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable-night/ic_home.xml b/app/src/main/res/drawable-night/ic_home.xml
new file mode 100644
index 0000000..c66f285
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_home.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable-night/ic_palette.xml b/app/src/main/res/drawable-night/ic_palette.xml
new file mode 100644
index 0000000..cb4385b
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_palette.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable-night/ic_sliders.xml b/app/src/main/res/drawable-night/ic_sliders.xml
new file mode 100644
index 0000000..eaa85cc
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_sliders.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 0000000..760187a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_app_settings.xml b/app/src/main/res/drawable/ic_app_settings.xml
new file mode 100644
index 0000000..ad608b9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_app_settings.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_controllerstyle.xml b/app/src/main/res/drawable/ic_controllerstyle.xml
new file mode 100644
index 0000000..3eb98b9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_controllerstyle.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml
new file mode 100644
index 0000000..e45e971
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml
new file mode 100644
index 0000000..a48e3c2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_palette.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sliders.xml b/app/src/main/res/drawable/ic_sliders.xml
new file mode 100644
index 0000000..a8aa26b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sliders.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..345888d
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..4c4d145
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..f40bdae
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..1935868
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..ea1f08a
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..dc22ab0
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..09b1828
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..70cf7b3
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..b785ce0
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..af29579
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..b501e78
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d38c882
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..bd624c1
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..99023c1
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..2feb526
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..66cabe3
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..030ed95
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..ae1f5c1
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..09e1442
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..9b5e86c
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..f4ec7fb
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..1ac6cbe
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..6b84e5d
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..a694d43
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,45 @@
+
+ OdinTools
+ Xbox
+ Odin
+ Disconnect
+ Controller Style
+ Unknown
+ L2/R2 Mode
+ Analog
+ Digital
+ Both
+ Device version:\nApp version:
+
+ Quick Settings
+ Select which modes to cycle through in the Controller Style quick settings tile
+ Select which modes to cycle through in the L2/R2 Mode quick settings tile
+
+ Buttons
+ Single press home button
+ When enabled allows using the home button with a single press instead of a double press
+
+ Warning
+ This app was made for the Ayn Odin 2. Running it on an incompatible device may cause damage. The author of this app takes no responsibility for any damage caused!
+ The app can\'t access your device settings. This might be caused by a change of functionality in a recent firmware update. If possible this will be fixed in an update. For now, the app can\'t be used. Sorry!
+ Accept
+ Close app
+ Close app 😢
+ Cancel
+ Save
+ Delete
+
+ Display saturation
+ Display
+ Change the saturation of the display. Will be re-applied on reboot. Default value is 1.0.
+
+ Used by OdinTools to detect running apps for dynamic switching of button settings
+
+ App overrides
+ Set up overrides per app. Switch off to globally disable app overrides.
+ Add app override
+ Delete override
+ Do you really want to delete this item?
+ No change
+ You already added overrides for all your apps
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..55b2772
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml
new file mode 100644
index 0000000..0f368c7
--- /dev/null
+++ b/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..186443b
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/de/langerhans/odintools/ExampleUnitTest.kt b/app/src/test/java/de/langerhans/odintools/ExampleUnitTest.kt
new file mode 100644
index 0000000..bb62a9f
--- /dev/null
+++ b/app/src/test/java/de/langerhans/odintools/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package de.langerhans.odintools
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..93674af
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,26 @@
+import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.com.android.application) apply false
+ alias(libs.plugins.org.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.com.google.dagger.hilt.android) apply false
+ alias(libs.plugins.com.google.devtools.ksp) apply false
+ alias(libs.plugins.androidx.room) apply false
+
+ alias(libs.plugins.com.github.ben.manes.versions)
+ alias(libs.plugins.nl.littlerobots.version.catalog.update)
+}
+
+fun isNonStable(version: String): Boolean {
+ val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) }
+ val regex = "^[0-9,.v-]+(-r)?$".toRegex()
+ val isStable = stableKeyword || regex.matches(version)
+ return isStable.not()
+}
+
+tasks.withType {
+ rejectVersionIf {
+ isNonStable(candidate.version) && !isNonStable(currentVersion)
+ }
+}
\ No newline at end of file
diff --git a/docs/app_overrides.png b/docs/app_overrides.png
new file mode 100644
index 0000000..2a7c28b
Binary files /dev/null and b/docs/app_overrides.png differ
diff --git a/docs/main.png b/docs/main.png
new file mode 100644
index 0000000..c4a0fea
Binary files /dev/null and b/docs/main.png differ
diff --git a/docs/qs_tiles.png b/docs/qs_tiles.png
new file mode 100644
index 0000000..d196f10
Binary files /dev/null and b/docs/qs_tiles.png differ
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..60806e9
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,25 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+
+android.enableBuildConfigAsBytecode=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..197010c
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,92 @@
+[versions]
+androidx-activity = "1.8.2"
+androidx-annotation-annotation-jvm = "1.7.1"
+androidx-compose-bom = "2024.01.00"
+androidx-hilt = "1.1.0"
+androidx-lifecycle = "2.7.0"
+androidx-navigation = "2.7.6"
+androidx-room = "2.6.1"
+androidx-test-espresso-espresso-core = "3.5.1"
+androidx-test-ext-junit = "1.1.5"
+com-android-application = "8.2.2"
+com-github-ben-manes-versions = "0.51.0"
+com-google-accompanist-accompanist-drawablepainter = "0.34.0"
+com-google-dagger = "2.50"
+com-google-dagger-hilt-android = "2.50"
+com-google-devtools-ksp = "1.9.22-1.0.17"
+junit = "4.13.2"
+nl-littlerobots-version-catalog-update = "0.8.4"
+org-jetbrains-kotlin-android = "1.9.22"
+
+[libraries]
+androidx-activity-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
+androidx-annotation-annotation-jvm = { module = "androidx.annotation:annotation-jvm", version.ref = "androidx-annotation-annotation-jvm" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
+androidx-compose-material3-material3 = { module = "androidx.compose.material3:material3" }
+androidx-compose-ui-ui = { module = "androidx.compose.ui:ui" }
+androidx-compose-ui-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
+androidx-compose-ui-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
+androidx-compose-ui-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
+androidx-compose-ui-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+androidx-compose-ui-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+androidx-hilt-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
+androidx-lifecycle-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
+androidx-navigation-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+androidx-room-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
+androidx-room-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
+androidx-room-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
+androidx-test-espresso-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso-espresso-core" }
+androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
+com-google-accompanist-accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "com-google-accompanist-accompanist-drawablepainter" }
+com-google-dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "com-google-dagger" }
+com-google-dagger-hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "com-google-dagger" }
+junit = { module = "junit:junit", version.ref = "junit" }
+
+[plugins]
+androidx-room = { id = "androidx.room", version.ref = "androidx-room" }
+com-android-application = { id = "com.android.application", version.ref = "com-android-application" }
+com-github-ben-manes-versions = { id = "com.github.ben-manes.versions", version.ref = "com-github-ben-manes-versions" }
+com-google-dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "com-google-dagger-hilt-android" }
+com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "com-google-devtools-ksp" }
+nl-littlerobots-version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "nl-littlerobots-version-catalog-update" }
+org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "org-jetbrains-kotlin-android" }
+
+[bundles]
+app = [
+ "androidx-activity-activity-compose",
+ "androidx-annotation-annotation-jvm",
+ "androidx-compose-material3-material3",
+ "androidx-compose-ui-ui",
+ "androidx-compose-ui-ui-graphics",
+ "androidx-compose-ui-ui-tooling-preview",
+ "androidx-hilt-hilt-navigation-compose",
+ "androidx-lifecycle-lifecycle-runtime-ktx",
+ "androidx-lifecycle-lifecycle-viewmodel-compose",
+ "androidx-lifecycle-lifecycle-viewmodel-ktx",
+ "androidx-navigation-navigation-compose",
+ "androidx-room-room-ktx",
+ "androidx-room-room-runtime",
+ "com-google-accompanist-accompanist-drawablepainter",
+ "com-google-dagger-hilt-android",
+]
+appAndroidTest = [
+ "androidx-compose-ui-ui-test-junit4",
+ "androidx-test-espresso-espresso-core",
+ "androidx-test-ext-junit",
+]
+appAnnotationProcessor = [
+ "androidx-room-room-compiler",
+]
+appDebug = [
+ "androidx-compose-ui-ui-test-manifest",
+ "androidx-compose-ui-ui-tooling",
+]
+appKsp = [
+ "androidx-room-room-compiler",
+ "com-google-dagger-hilt-android-compiler",
+]
+appUnitTest = [
+ "junit",
+]
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..2500b9f
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Dec 29 22:32:25 CET 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# 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
+#
+# https://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.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..b0476dc
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "OdinTools"
+include(":app")
+
\ No newline at end of file