Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Repository Guidelines

## Project Structure & Module Organization

Geto is a multi-module Android app written in Kotlin and Jetpack Compose. `app/` contains the
application, activities, navigation, and manifest. Keep UI features in `feature/` (`apps`,
`app-settings`, `home`, `settings`), reusable Compose pieces in `design-system/` and `ui/`, and
background protection code in `service/`.

The clean architecture layers are `domain/` (models, repository interfaces, use cases), `data/`
(DataStore, Room, repository implementations), and `framework/` (Android API adapters). Put Room
entities, DAOs, migrations, and schemas in `data/room/`; protobuf definitions are in
`data/datastore-proto/src/main/proto/`. Assets such as setting templates belong under the owning
module's `src/main/assets/`.

## Build, Test, and Development Commands

Run commands from the repository root. Ensure `ANDROID_HOME` points to an installed Android SDK.

- `./gradlew :app:assembleDebug` builds the debug APK.
- `./gradlew testDebugUnitTest` runs Android module unit tests; use a module task such as
`./gradlew :domain:use-case:test` for focused work.
- `./gradlew :data:room:connectedDebugAndroidTest` runs Room migration tests on a connected device
or emulator.
- `./gradlew lintDebug` runs Android lint.
- `./gradlew spotlessApply --init-script gradle/init.gradle.kts` formats Kotlin, Gradle Kotlin,
and XML; use `spotlessCheck` in review/CI checks.

## Coding Style & Naming Conventions

Use four-space indentation and Kotlin idioms. Spotless/Ktlint is authoritative; run it before
committing. Follow existing package names under `com.android.geto`. Name Compose screens
`*Screen`, ViewModels `*ViewModel`, UI state classes `*UiState`, Hilt modules `*Module`, and use
cases as verb-led `*UseCase`. Preserve the GPL header used by nearby Kotlin and Gradle files.

## Testing Guidelines

Place unit tests in `src/test/kotlin` and device tests in `src/androidTest/kotlin`. Use descriptive
test names that state the behavior, e.g. `migrate9To10_preservesProfilesAndAddsEmptyProtectionTables`.
Add tests for use-case transactions, ViewModel state, and every Room migration; update the schema
JSON when changing Room entities.

## Commit & Pull Request Guidelines

Use concise Conventional Commit-style subjects seen in history: `feat:`, `fix:`, `refactor:`, or
`chore:`. Keep commits focused. PRs should explain behavior and risk, link the issue when present,
list validation commands, and include screenshots or recordings for visible Compose UI changes.
Never commit SDK paths, local properties, signing keys, or device-specific data.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ Apply device settings to your apps
About The Project
==================

The only reason I created this app is to turn off that damn Developer Options when using a banking
app. The only annoying thing about it is you have to go to the Settings app. When you turn off that
switch button, your Developer Options configurations will be reset to default. The good thing is
that when you modify your settings through its Shared Preferences, you won't lose all your settings
once the Developer Options is modified. So basically, you have to grant this app
with `android.permission.WRITE_SECURE_SETTINGS` in order for it to modify your Settings values.
Geto applies a saved Android-settings profile before opening an app—for example, temporarily hiding
Developer Options from a banking app. Geto needs `android.permission.WRITE_SECURE_SETTINGS` to read
and update those values. Grant it from **Settings → Permission**, either with Shizuku on the device
itself or by copying the ADB command and running it from a connected computer.

Use **Launch once** for temporary changes. Geto snapshots the real original values, applies the
profile as one recoverable transaction, and keeps a Restore notification until the originals are
verified. Use **Keep profile active** when the target must also work from its original launcher icon.
In that mode a lightweight foreground service watches only the selected setting keys and repairs
drift without polling or holding a wake lock.

Settings includes an optional **Restart protection automatically** control for unexpected service
stops, reboots, and app updates. It is off by default to avoid background work unless the user opts
in. Geto reports interrupted or incomplete recovery through its protection notifications.

> [!IMPORTANT]
> Watch the tutorial on [YouTube](https://youtu.be/CJrJyHpVVRM?si=ACrEC0hcPed53RAj)
Expand Down
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,16 @@ dependencies {

implementation(projects.framework.assetManager)
implementation(projects.framework.drawable)
implementation(projects.framework.foregroundApp)
implementation(projects.framework.launcherApps)
implementation(projects.framework.notificationManager)
implementation(projects.framework.packageManager)
implementation(projects.framework.secureSettings)
implementation(projects.framework.shizuku)
implementation(projects.framework.shortcutManager)
implementation(projects.service)
implementation(projects.ui)

implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/kotlin/com/android/geto/GetoApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import android.app.Application
import android.app.NotificationManager
import android.os.Build
import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper
import com.android.geto.service.ForegroundProtectionCoordinator
import com.android.geto.service.ProtectionServiceManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

Expand All @@ -29,6 +31,12 @@ class GetoApplication : Application() {
@Inject
lateinit var notificationManagerWrapper: AndroidNotificationManagerWrapper

@Inject
lateinit var protectionServiceManager: ProtectionServiceManager

@Inject
lateinit var foregroundProtectionCoordinator: ForegroundProtectionCoordinator

override fun onCreate() {
super.onCreate()

Expand All @@ -38,6 +46,29 @@ class GetoApplication : Application() {
name = getString(R.string.app_name),
importance = NotificationManager.IMPORTANCE_DEFAULT,
)

notificationManagerWrapper.createNotificationChannel(
channelId = AndroidNotificationManagerWrapper.PROTECTION_NOTIFICATION_CHANNEL_ID,
name = getString(com.android.geto.framework.notificationmanager.R.string.protection_channel_name),
importance = NotificationManager.IMPORTANCE_LOW,
description = getString(
com.android.geto.framework.notificationmanager.R.string.protection_channel_description,
),
)

notificationManagerWrapper.createNotificationChannel(
channelId = AndroidNotificationManagerWrapper.PROTECTION_ALERT_CHANNEL_ID,
name = getString(com.android.geto.framework.notificationmanager.R.string.protection_alert_channel_name),
importance = NotificationManager.IMPORTANCE_DEFAULT,
description = getString(
com.android.geto.framework.notificationmanager.R.string.protection_alert_channel_description,
),
)
}

protectionServiceManager.initialize()
// Restores anything left applied by a previous process before it starts watching, so a
// profile can never outlive the run that applied it.
foregroundProtectionCoordinator.initialize()
}
}
68 changes: 66 additions & 2 deletions app/src/main/kotlin/com/android/geto/activity/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,28 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.android.geto.R
import com.android.geto.designsystem.theme.GetoTheme
import com.android.geto.domain.model.Theme
import com.android.geto.framework.launcherapps.AndroidLauncherAppsWrapper
import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper
import com.android.geto.navigation.GetoNavHost
import com.android.geto.service.ProtectionServiceManager
import com.android.geto.ui.local.LocalLauncherApps
import com.android.geto.ui.local.LocalNotificationManager
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -45,15 +57,22 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var androidNotificationManagerWrapper: AndroidNotificationManagerWrapper

@Inject
lateinit var protectionServiceManager: ProtectionServiceManager

private val viewModel: MainActivityViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
val splashScreen = installSplashScreen()

enableEdgeToEdge()

super.onCreate(savedInstanceState)

splashScreen.setKeepOnScreenCondition {
viewModel.uiState.value is MainActivityUiState.Loading
}

setContent {
CompositionLocalProvider(
LocalLauncherApps provides androidLauncherAppsWrapper,
Expand All @@ -64,7 +83,29 @@ class MainActivity : ComponentActivity() {
val mainActivityUiState by viewModel.uiState.collectAsStateWithLifecycle()

when (val uiState = mainActivityUiState) {
MainActivityUiState.Loading -> Unit
MainActivityUiState.Loading -> {
LoadingContent()
}

is MainActivityUiState.Error -> {
GetoTheme(
theme = Theme.FOLLOW_SYSTEM,
dynamicTheme = false,
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = stringResource(R.string.preferences_load_failed))
Button(onClick = viewModel::retry) {
Text(text = stringResource(R.string.retry))
}
}
}
}
}

is MainActivityUiState.Success -> {
GetoTheme(
Expand All @@ -80,4 +121,27 @@ class MainActivity : ComponentActivity() {
}
}
}

override fun onStart() {
super.onStart()
protectionServiceManager.reconcileFromVisibleApp()
}
}

@androidx.compose.runtime.Composable
private fun LoadingContent() {
GetoTheme(
theme = Theme.FOLLOW_SYSTEM,
dynamicTheme = false,
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ sealed interface MainActivityUiState {
data object Loading : MainActivityUiState

data class Success(val userData: UserData) : MainActivityUiState

data class Error(val message: String?) : MainActivityUiState
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,39 @@ package com.android.geto.activity.main

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.geto.domain.model.UserData
import com.android.geto.domain.repository.UserDataRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import javax.inject.Inject

@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository,
private val userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState = userDataRepository.userData.map(MainActivityUiState::Success).stateIn(
private val retryTrigger = MutableStateFlow(0)

val uiState = retryTrigger.flatMapLatest {
userDataRepository.userData
.map<UserData, MainActivityUiState>(MainActivityUiState::Success)
.onStart { emit(MainActivityUiState.Loading) }
.catch { throwable -> emit(MainActivityUiState.Error(throwable.message)) }
}.stateIn(
scope = viewModelScope,
initialValue = MainActivityUiState.Loading,
started = SharingStarted.WhileSubscribed(5_000),
)

fun retry() {
retryTrigger.update(Int::inc)
}
}
Loading