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