├── .github ├── FUNDING.yml └── workflows │ ├── pr.yaml │ └── publish.yaml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── flx_apps │ │ └── digitaldetox │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── flx_apps │ │ │ └── digitaldetox │ │ │ ├── Constants.kt │ │ │ ├── DetoxDroidApplication.kt │ │ │ ├── MainActivity.kt │ │ │ ├── data │ │ │ ├── DataStore.kt │ │ │ └── DataStoreProperty.kt │ │ │ ├── feature_types │ │ │ ├── +Feature.kt │ │ │ ├── NeedsPermissionsFeature.kt │ │ │ ├── ScreenTimeTrackingFeature.kt │ │ │ ├── SubscriptionFeature.kt │ │ │ ├── SupportsAppExceptionsFeature.kt │ │ │ └── SupportsScheduleFeature.kt │ │ │ ├── features │ │ │ ├── +FeaturesProvider.kt │ │ │ ├── BreakDoomScrollingFeature.kt │ │ │ ├── DisableAppsFeature.kt │ │ │ ├── DoNotDisturbFeature.kt │ │ │ ├── GrayscaleAppsFeature.kt │ │ │ └── PauseButtonFeature.kt │ │ │ ├── system_integration │ │ │ ├── DetoxDroidAccessibilityService.kt │ │ │ ├── DetoxDroidDeviceAdminReceiver.kt │ │ │ ├── OverlayService.kt │ │ │ ├── PauseInteractionService.kt │ │ │ ├── PauseTileService.kt │ │ │ ├── ScreenTurnedOffReceiver.kt │ │ │ └── UsageStatsProvider.kt │ │ │ ├── ui │ │ │ ├── screens │ │ │ │ ├── app_exceptions │ │ │ │ │ ├── AppExceptionsScreen.kt │ │ │ │ │ └── AppExceptionsViewModel.kt │ │ │ │ ├── feature │ │ │ │ │ ├── FeatureScreen.kt │ │ │ │ │ ├── FeatureViewModel.kt │ │ │ │ │ ├── OpenAppExceptionsTile.kt │ │ │ │ │ ├── OpenScheduleTile.kt │ │ │ │ │ ├── break_doom_scrolling │ │ │ │ │ │ ├── BreakDoomScrollingFeatureSettingsSection.kt │ │ │ │ │ │ ├── BreakDoomScrollingFeatureSettingsViewModel.kt │ │ │ │ │ │ └── BreakDoomScrollingOverlay.kt │ │ │ │ │ ├── disable_apps │ │ │ │ │ │ ├── AppDisabledOverlay.kt │ │ │ │ │ │ ├── DisableAppsFeatureSettingsSection.kt │ │ │ │ │ │ ├── DisableAppsFeatureSettingsViewModel.kt │ │ │ │ │ │ └── OpenDisabledAppsTile.kt │ │ │ │ │ ├── do_not_disturb │ │ │ │ │ │ ├── DoNotDisturbFeatureSettingsSection.kt │ │ │ │ │ │ └── DoNotDisturbFeatureSettingsViewModel.kt │ │ │ │ │ ├── grayscale_apps │ │ │ │ │ │ ├── GrayscaleAppsFeatureSettingsSection.kt │ │ │ │ │ │ └── GrayscaleAppsFeatureSettingsViewModel.kt │ │ │ │ │ └── pause_button │ │ │ │ │ │ ├── PauseButtonFeatureSettingsSection.kt │ │ │ │ │ │ └── PauseButtonFeatureSettingsViewModel.kt │ │ │ │ ├── home │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ └── HomeViewModel.kt │ │ │ │ ├── nav_host │ │ │ │ │ ├── NavHostScreen.kt │ │ │ │ │ ├── NavViewModel.kt │ │ │ │ │ └── NavigationRoutes.kt │ │ │ │ ├── permissions_required │ │ │ │ │ └── PermissionsRequiredScreen.kt │ │ │ │ └── schedule │ │ │ │ │ ├── ScheduleScreen.kt │ │ │ │ │ └── ScheduleViewModel.kt │ │ │ ├── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── widgets │ │ │ │ ├── Center.kt │ │ │ │ ├── DropdownIconButton.kt │ │ │ │ ├── HyperlinkText.kt │ │ │ │ ├── Indicator.kt │ │ │ │ ├── InfoCard.kt │ │ │ │ ├── NumberPickerDialog.kt │ │ │ │ ├── OptionsRow.kt │ │ │ │ └── SimpleListTile.kt │ │ │ └── util │ │ │ ├── AccessibilityEventUtil.kt │ │ │ ├── ApplicationInfoExt.kt │ │ │ ├── ComposableExt.kt │ │ │ ├── DurationExt.kt │ │ │ ├── KeyEventUtil.kt │ │ │ ├── NavigationUtil.kt │ │ │ ├── RootShellCommand.kt │ │ │ └── SelfExpiringHashMap.java │ └── res │ │ ├── drawable │ │ ├── ic_app_exceptions.xml │ │ ├── ic_contrast.xml │ │ ├── ic_disable_app.xml │ │ ├── ic_do_not_disturb.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_foreground_cropped.xml │ │ ├── ic_launcher_foreground_outlined.xml │ │ ├── ic_pause.xml │ │ ├── ic_schedule.xml │ │ ├── ic_scroll.xml │ │ ├── ic_start.xml │ │ └── ic_stop.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── accessibility_service_config.xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ ├── device_admin_config.xml │ │ └── pause_interaction_service.xml │ └── test │ └── java │ └── com │ └── flx_apps │ └── digitaldetox │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 10000.txt │ ├── 10001.txt │ ├── 10002.txt │ ├── 11000.txt │ ├── 12000.txt │ ├── 12001.txt │ ├── 12002.txt │ ├── 20000.txt │ ├── 20001.txt │ ├── 20002.txt │ ├── 20003.txt │ ├── 20004.txt │ ├── 20005.txt │ ├── 20006.txt │ └── 20007.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ └── 5.jpg │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── install ├── README.txt ├── install_linux.sh ├── install_mac.sh └── install_windows.bat └── settings.gradle.kts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: flxapps 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: DetoxDroid 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://paypal.me/felixheller', 'https://www.paypal.com/donate/?hosted_button_id=K6T2HPXE7HQBG'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Android PR Checks 2 | 3 | on: 4 | pull_request: 5 | branches: [ main, develop ] 6 | 7 | jobs: 8 | build: 9 | name: Build and Test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up JDK 17 | uses: actions/setup-java@v3 18 | with: 19 | distribution: 'temurin' 20 | java-version: '17' 21 | cache: gradle 22 | 23 | - name: Grant execute permission for gradlew 24 | run: chmod +x gradlew 25 | 26 | - name: Check code style 27 | run: ./gradlew ktlintCheck || true 28 | 29 | - name: Run unit tests 30 | run: ./gradlew testDebugUnitTest 31 | 32 | - name: Build debug APK 33 | run: ./gradlew assembleDebug 34 | 35 | - name: Cache Gradle packages 36 | uses: actions/cache@v3 37 | with: 38 | path: | 39 | ~/.gradle/caches 40 | ~/.gradle/wrapper 41 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 42 | restore-keys: | 43 | ${{ runner.os }}-gradle- 44 | 45 | - name: Upload build reports 46 | if: always() 47 | uses: actions/upload-artifact@v3 48 | with: 49 | name: build-reports 50 | path: | 51 | */build/reports/ 52 | 53 | - name: Check for conventional commit format 54 | run: | 55 | PR_TITLE="${{ github.event.pull_request.title }}" 56 | if ! echo "$PR_TITLE" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .+$'; then 57 | echo "Warning: PR title doesn't follow conventional commit format." 58 | echo "Expected format: 'type(scope): description' where type is one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" 59 | echo "Example: 'feat(ui): add new login screen'" 60 | exit 0 # Warning only, don't fail the build 61 | fi 62 | 63 | - name: Validate version consistency 64 | run: | 65 | # Compare version in build.gradle and fastlane metadata (if exists) 66 | if [ -f "app/build.gradle.kts" ]; then 67 | GRADLE_FILE="app/build.gradle.kts" 68 | elif [ -f "build.gradle.kts" ]; then 69 | GRADLE_FILE="build.gradle.kts" 70 | else 71 | echo "Could not find build.gradle file" 72 | exit 0 73 | fi 74 | 75 | # Just verify that version info exists in build file 76 | if ! grep -q "versionCode\|version =" "$GRADLE_FILE"; then 77 | echo "Warning: Version information not found in Gradle file" 78 | exit 0 79 | fi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /.idea/ 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DetoxDroid 2 | 3 | ![GitHub Repo stars](https://img.shields.io/github/stars/flxapps/DetoxDroid) 4 | [![GitHub release](https://img.shields.io/github/release/flxapps/DetoxDroid.svg)](https://github.com/flxapps/DetoxDroid/releases/) [![GitHub license](https://img.shields.io/github/license/flxapps/DetoxDroid.svg)](https://github.com/flxapps/DetoxDroid/blob/master/LICENSE) [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/flxapps/DetoxDroid/graphs/commit-activity) [![Ko-Fi](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=3%20EUR&color=red)](https://ko-fi.com/flxapps/3) [![LiberaPay](https://img.shields.io/liberapay/receives/DetoxDroid)](https://liberapay.com/DetoxDroid) 5 | 6 | ## Digital Detoxing as Your New Default 7 | 8 | 9 | 10 | ## What it is about 11 | 12 | Usually, "Digital Detoxing" apps in Android are about opting-in for a contract to not use our phones for the next X minutes. Or that we will not use the app Y for more than Z minutes. These contracts are often reinforced with financial incentives, i.e. if we fail we will have to pay a fee of a few dollars to the developers. 13 | 14 | This app follows a different approach by stripping away all the attention-grabbing and manipulative features of other apps. DetoxDroid does not require you to choose a time and place when you want to "detox", or punish you if you fail. Instead, it aims to enable you to use your phone rather than letting your phone use you. 15 | 16 | ## Features 17 | 18 | ### 1. Grayscale Your Screen While Making Exceptions 19 | According to former Google design ethicist Tristan Harris, founder of the Center for Humane Technology, going grayscale removes positive reinforcements and dampens compulsive smartphone use. But while this might prove helpful in regards to reducing your smartphone time, there are obvious downsides. For example, you do want to see colors on your screen when you take a photo. With DetoxDroid you can allow specific apps to show colors, while the rest of your phone stays gray. 20 | 21 | ### 2. Automatically enter the "Do Not Disturb" mode 22 | Notifications are the number one way for apps to draw our attention. Any app on our phones competes for our attention, and even games will remind us to play them if we have not opened them for a while. Reclaim your time by turning them off with the "Do Not Disturb" mode. 23 | 24 | ### 3. Make Apps Disappear 25 | You can select apps that will completely vanish and be deactivated while DetoxDroid is running. Move Twitter, Reddit and other distractions into this vault during your productive hours and only open these apps, when you deliberately take pauses. Hence, you can completely forget about them, rather than being bothered by lockout screens. 26 | 27 | ### 4. Break "Infinite Scrolling" 28 | Infinite scrolling (sometimes also referred as "doom scrolling") is a manipulative design technology that eliminates those brief moments where you might turn to another activity. DetoxDroid can try to detect if you have been lost in such behavior and offer you an exit strategy. 29 | 30 | ### 5. Opt-out > Opt-in 31 | You are encouraged to deliberately to pause DetoxDroid, allowing colors and notifications. If the pause is over, DetoxDroid automatically comes back into life. Let this become your new default rather than having to repeatedly decide for it. 32 | 33 | ## Installation 34 | 1. [Enable developer mode and USB debugging](https://www.youtube.com/watch?v=0usgePpr8_Y): 35 | 1. Go to Android Settings → About Phone 36 | 2. Look for the build number option and touch it multiple times until developer mode is enabled 37 | 3. Go to Android Settings → Developer Options, look for USB debugging and enable it 38 | (Some devices might require enabling the "USB Debugging (Security Settings)" option or else `pm` commands won't work at all. Certain Mi devices require you to sign into a Mi Account to enable this feature.) 39 | 4. Connect your device with your computer. 40 | 2. [Download, unzip and run the installation script](https://downgit.github.io/#/home?url=https://github.com/flxapps/DetoxDroid/tree/master/install) for your OS 41 | - If you are on Windows, you should be able to run `install_windows.bat` by simply double-clicking the file. 42 | - If you are on Mac/Linux, you should be able to run the installation script from the console using `bash install_.sh` 43 | - If at any point a prompt on your phone asks you whether you want to allow debugging by your computer, press Allow. 44 | 3. Optionally, disable USB debugging again. 45 | 46 | ### Alternative Method 47 | If the installation script does not work or you do not trust it, follow the [manual installation steps](https://github.com/flxapps/DetoxDroid/wiki/Manual-Installation). 48 | 49 | ## Support 50 | If you like the project, feel free to support further development. 51 | - [Submit feature suggestions and bug reports](https://github.com/flxapps/DetoxDroid/issues/new) 52 | - [Buy me a coffee via Ko-Fi](https://ko-fi.com/flxapps) 53 | - [Become a patron on LiberaPay](https://liberapay.com/DetoxDroid/donate) 54 | - [Donate via PayPal](https://www.paypal.com/donate/?cmd=_s-xclick&hosted_button_id=K6T2HPXE7HQBG) 55 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("kotlin-parcelize") 5 | // DI 6 | id("kotlin-kapt") 7 | id("dagger.hilt.android.plugin") 8 | } 9 | 10 | android { 11 | namespace = "com.flx_apps.digitaldetox" 12 | compileSdk = 35 13 | 14 | defaultConfig { 15 | applicationId = "com.flx_apps.digitaldetox" 16 | minSdk = 26 17 | targetSdk = 33 18 | versionCode = 20007 19 | versionName = "2.0.7" 20 | 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | vectorDrawables { 23 | useSupportLibrary = true 24 | } 25 | } 26 | 27 | buildTypes { 28 | release { 29 | isMinifyEnabled = false 30 | proguardFiles( 31 | getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" 32 | ) 33 | } 34 | } 35 | compileOptions { 36 | sourceCompatibility = JavaVersion.VERSION_17 37 | targetCompatibility = JavaVersion.VERSION_17 38 | } 39 | kotlinOptions { 40 | jvmTarget = "17" 41 | compileOptions { 42 | languageVersion = "1.9" 43 | } 44 | } 45 | buildFeatures { 46 | compose = true 47 | } 48 | composeOptions { 49 | kotlinCompilerExtensionVersion = "1.4.3" 50 | } 51 | packaging { 52 | resources { 53 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 54 | } 55 | } 56 | } 57 | 58 | dependencies { 59 | // Core dependencies 60 | implementation("androidx.core:core-ktx:1.15.0") 61 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") 62 | implementation("androidx.activity:activity-compose:1.10.1") 63 | implementation(platform("androidx.compose:compose-bom:2025.02.00")) 64 | implementation("androidx.compose.ui:ui") 65 | implementation("androidx.compose.ui:ui-graphics") 66 | implementation("androidx.compose.ui:ui-tooling-preview") 67 | implementation("androidx.compose.material3:material3") 68 | implementation("androidx.compose.runtime:runtime-livedata") // observeAsState() extension function 69 | implementation("androidx.datastore:datastore-core:1.1.3") 70 | testImplementation("junit:junit:4.13.2") 71 | androidTestImplementation("androidx.test.ext:junit:1.2.1") 72 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") 73 | androidTestImplementation(platform("androidx.compose:compose-bom:2025.02.00")) 74 | androidTestImplementation("androidx.compose.ui:ui-test-junit4") 75 | debugImplementation("androidx.compose.ui:ui-tooling") 76 | debugImplementation("androidx.compose.ui:ui-test-manifest") 77 | 78 | // Preferences / DataStore 79 | implementation("androidx.datastore:datastore-preferences:1.1.3") 80 | 81 | // Timber (Logging) 82 | implementation("com.jakewharton.timber:timber:5.0.1") 83 | 84 | // DI 85 | implementation("com.google.dagger:hilt-android:2.49") 86 | kapt("com.google.dagger:hilt-android-compiler:2.49") 87 | implementation("androidx.hilt:hilt-work:1.2.0") 88 | kapt("androidx.hilt:hilt-compiler:1.2.0") 89 | implementation("androidx.work:work-runtime-ktx:2.10.0") 90 | implementation("androidx.hilt:hilt-navigation-compose:1.2.0") 91 | 92 | // Navigation Library 93 | implementation("dev.olshevski.navigation:reimagined:1.5.0") 94 | implementation("dev.olshevski.navigation:reimagined-hilt:1.5.0") 95 | implementation(kotlin("reflect")) 96 | 97 | // Number picker 98 | implementation("com.chargemap.compose:numberpicker:1.0.3") 99 | 100 | // ViewTreeLifecycleOwner 101 | implementation("androidx.lifecycle:lifecycle-service:2.8.7") 102 | 103 | // Chart Engine 104 | implementation("co.yml:ycharts:2.1.0") 105 | 106 | // Material Icons 107 | implementation("androidx.compose.material:material-icons-extended") 108 | 109 | // RootTools for running (adb) commands as root 110 | implementation("com.github.Stericson:RootShell:1.6") 111 | } 112 | 113 | kotlin.sourceSets.all { 114 | languageSettings.enableLanguageFeature("DataObjects") 115 | } 116 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/flx_apps/digitaldetox/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.flx_apps.digitaldetox", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flxapps/DetoxDroid/07767fe215f30935e4143fff9dcba12febb6970b/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox 2 | 3 | /** 4 | * The number of milliseconds for one minute. 5 | */ 6 | const val OneMinuteInMs = 60 * 1000L 7 | 8 | /** 9 | * The number of milliseconds for one second. 10 | */ 11 | const val TenSecondsInMs = 10 * 1000L -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/DetoxDroidApplication.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | /** 7 | * The main application class. It is used to initialize the dependency injection framework. 8 | */ 9 | @HiltAndroidApp 10 | class DetoxDroidApplication : Application() { 11 | companion object { 12 | lateinit var appContext: Application 13 | } 14 | 15 | override fun onCreate() { 16 | super.onCreate() 17 | appContext = this 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.ui.Modifier 10 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavHostScreen 11 | import com.flx_apps.digitaldetox.ui.theme.DetoxDroidTheme 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import timber.log.Timber 14 | 15 | /** 16 | * From the main activity, the user can turn on/off and configure the app's features. 17 | */ 18 | @AndroidEntryPoint 19 | class MainActivity : ComponentActivity() { 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | Timber.plant(Timber.DebugTree()) 23 | setContent { 24 | DetoxDroidTheme(darkTheme = false) { // A surface container using the 'background' color from the theme 25 | Surface( 26 | modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background 27 | ) { 28 | NavHostScreen() 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/data/DataStore.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.data 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.edit 7 | import androidx.datastore.preferences.preferencesDataStore 8 | import com.flx_apps.digitaldetox.DetoxDroidApplication 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.launch 11 | import kotlinx.coroutines.runBlocking 12 | 13 | /** 14 | * The data store instance that is used to persist the preferences across app restarts. 15 | * TODO This is always initialized with the app context. Using dependency injection would be better, 16 | * but this fairly pragmatic solution works for now. 17 | * @see persistValue 18 | * @see loadValues 19 | */ 20 | val DataStore: DataStore by lazy { DetoxDroidApplication.appContext.dataStore } 21 | 22 | /** 23 | * The data store that is used to persist the preferences across app restarts. It is initialized 24 | * with the name of the preferences file. The file is stored in the app's private data directory. 25 | * @see persistValue 26 | * @see loadValues 27 | */ 28 | val Context.dataStore by preferencesDataStore("detox_droid_preferences") 29 | 30 | /** 31 | * Helper function to persist a value to the data store. It will just launch a coroutine in the 32 | * background and we just assume that the operation was successful, as we also store the state 33 | * in a local variable (which will be used over the data store value). 34 | * 35 | * The data store is only used to persist the preferences across app restarts. 36 | */ 37 | fun DataStore.persistValue(key: Preferences.Key, value: T) { 38 | runBlocking { 39 | launch { 40 | this@persistValue.edit { preferences -> 41 | preferences[key] = value 42 | } 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * Convenience function to load a value from the data store. It will just return the first value 49 | * that is emitted by the data store flow. 50 | * 51 | * If no value is found, null is returned. 52 | * 53 | * This function is blocking for simplicity reasons. It should only be used in the initialization 54 | * of a property. (See [DataStoreProperty] for an example.) 55 | */ 56 | fun DataStore.loadValue(key: Preferences.Key<*>): T? { 57 | return runBlocking { 58 | val data = data.first() 59 | data[key] as T? 60 | } 61 | } 62 | 63 | 64 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/data/DataStoreProperty.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.data 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import kotlin.properties.ReadWriteProperty 6 | import kotlin.reflect.KProperty 7 | 8 | /** 9 | * An elaborate delegate for a property that is persisted in the data store. The property has a 10 | * type T and a representation type for the data store (DataStoreType). The property is persisted 11 | * under the given key in the data store. The value is loaded from the data store on initialization 12 | * and is persisted to the data store whenever it is changed. 13 | * @param defaultValue The initial (default) value of the property (if no value is found in the 14 | * data store). 15 | * @param key The key under which the value is persisted in the data store. 16 | * @param dataStore The data store to use. Defaults to the app's data store. 17 | * @param dataTransformer A mapper to convert the value to the data store type and vice versa. If no 18 | * mapper is given, the value is assumed to be of the data store type. 19 | */ 20 | @Suppress("UNCHECKED_CAST") 21 | class DataStoreProperty( 22 | private val key: Preferences.Key, 23 | private var defaultValue: T, 24 | private val dataTransformer: DataStorePropertyTransformer? = null, 25 | private val dataStore: DataStore = DataStore, 26 | ) { 27 | operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadWriteProperty { 28 | return object : ReadWriteProperty { 29 | /** 30 | * Returns the value of the property. On initialization, the value is loaded from the 31 | * data store (see [DataStoreProperty] init block). 32 | */ 33 | override fun getValue(thisRef: Any?, property: KProperty<*>): T { 34 | return defaultValue 35 | } 36 | 37 | /** 38 | * Persist the value to the data store whenever it is changed. 39 | * For simplicity reasons, we just assume that the operation was successful. 40 | */ 41 | override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { 42 | defaultValue = value 43 | dataStore.persistValue( 44 | key, dataTransformer?.transformTo(value) ?: value as DataStoreType 45 | ) 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * Load the value from the data store on initialization. 52 | */ 53 | init { 54 | dataStore.loadValue(key)?.let { 55 | defaultValue = dataTransformer?.transformFrom(it) ?: it as T 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * A data transformer that is used to convert the value of a property to the data store type and 62 | * vice versa. 63 | * @param T The type of the property. 64 | * @param DataStoreType The type that is used to persist the property in the data store. 65 | */ 66 | interface DataStorePropertyTransformer { 67 | /** 68 | * Transforms the value to the data store type. 69 | */ 70 | fun transformTo(value: T): DataStoreType 71 | 72 | /** 73 | * Transforms the value from the data store type. 74 | */ 75 | fun transformFrom(value: DataStoreType): T 76 | 77 | /** 78 | * A data transformer for enums. It just uses the enum name as the data store type. 79 | */ 80 | class EnumStorePropertyTransformer>(private val enumClass: Class) : 81 | DataStorePropertyTransformer { 82 | override fun transformTo(value: T): String { 83 | return value.name 84 | } 85 | 86 | override fun transformFrom(value: String): T { 87 | return java.lang.Enum.valueOf(enumClass, value) 88 | } 89 | } 90 | 91 | /** 92 | * A data transformer for sets. It uses the given functions to convert the items of the set to 93 | * and from strings. (String sets are supported by the data store.) 94 | */ 95 | class SetStorePropertyTransformer( 96 | private val itemToString: (T) -> String, private val itemFromString: (String) -> T? 97 | ) : DataStorePropertyTransformer, Set> { 98 | override fun transformTo(value: Set): Set { 99 | return value.map { itemToString(it) }.toSet() 100 | } 101 | 102 | override fun transformFrom(value: Set): Set { 103 | return value.mapNotNull { itemFromString(it) }.toSet() 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/feature_types/+Feature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.feature_types 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import androidx.compose.runtime.Composable 6 | import androidx.datastore.preferences.core.booleanPreferencesKey 7 | import com.flx_apps.digitaldetox.data.DataStoreProperty 8 | import java.time.LocalDateTime 9 | 10 | /** 11 | * The texts that are displayed for each feature. 12 | */ 13 | data class FeatureTexts( 14 | @StringRes val title: Int, 15 | @StringRes val subtitle: Int, 16 | @StringRes val description: Int, 17 | ) 18 | 19 | /** 20 | * The feature id right now corresponds to the simple name of the feature class. We could also 21 | * use a UUID or something like that in the future, hence the typealias. 22 | * @see Feature.createId 23 | */ 24 | typealias FeatureId = String 25 | 26 | /** 27 | * A feature is a module of the app that can be turned on/off by the user. 28 | * Each feature has a title, a description, a subtitle. 29 | * It can react to app events and scroll events. 30 | * It also contains helper methods that provides simple access to the data store. 31 | * 32 | * This is an abstract class, because each feature has to implement the methods differently. 33 | */ 34 | abstract class Feature { 35 | companion object { 36 | /** 37 | * Creates a feature id from the given feature class. 38 | * @see FeatureId 39 | */ 40 | fun createId(featureClass: Class): FeatureId { 41 | return featureClass.simpleName 42 | } 43 | } 44 | 45 | /** 46 | * The unique id of the feature. 47 | */ 48 | val id: FeatureId = createId(this::class.java) 49 | 50 | /** 51 | * The title, description, subtitle of the feature. 52 | */ 53 | abstract val texts: FeatureTexts 54 | 55 | /** 56 | * The icon that is displayed for the feature. 57 | */ 58 | abstract val iconRes: Int 59 | 60 | /** 61 | * A composable function that displays additional settings for the feature. 62 | * Typically, you would implement this in the [com.flx_apps.digitaldetox.ui.screens.feature].{feature_name} package. 63 | * 64 | * TODO move texts, iconRes, settingsContent to a separate class "FeatureUiInfo" or something like that 65 | */ 66 | abstract val settingsContent: @Composable () -> Unit 67 | 68 | /** 69 | * Whether the feature is currently active. 70 | */ 71 | open var isActivated: Boolean by DataStoreProperty( 72 | booleanPreferencesKey("${id}_isActivated"), false 73 | ) 74 | 75 | /** 76 | * Called for each [Feature] when DetoxDroid is started or when the feature is activated. 77 | */ 78 | open fun onStart(context: Context) {} 79 | 80 | /** 81 | * Called for each [Feature] when DetoxDroid is paused (temporarily) or stopped, or when the 82 | * feature is deactivated. 83 | */ 84 | open fun onPause(context: Context) {} 85 | 86 | /** 87 | * Returns whether the feature is currently active (i.e. whether it is activated and whether 88 | * there is a schedule rule that is active). 89 | */ 90 | fun isActive(atDateTime: LocalDateTime = LocalDateTime.now()): Boolean { 91 | return isActivated && (this !is SupportsScheduleFeature || isScheduled(atDateTime)) 92 | } 93 | } 94 | 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/feature_types/NeedsPermissionsFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.feature_types 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import android.provider.Settings 6 | import com.flx_apps.digitaldetox.R 7 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavViewModel 8 | import com.flx_apps.digitaldetox.util.NavigationUtil 9 | 10 | /** 11 | * A feature that needs specific permissions in order to work. 12 | * @see [hasPermissions] 13 | * @see [requestPermissions] 14 | */ 15 | interface NeedsPermissionsFeature { 16 | /** 17 | * Will be called before the feature is activated. If this method returns false, the feature 18 | * cannot be activated and a Snackbar will be shown to request the permissions. 19 | */ 20 | fun hasPermissions(context: Context): Boolean 21 | 22 | /** 23 | * This method will be called when the user clicks on the Snackbar to request the permissions. 24 | */ 25 | fun requestPermissions(context: Context, navViewModel: NavViewModel) 26 | } 27 | 28 | /** 29 | * A feature that needs the [android.Manifest.permission.SYSTEM_ALERT_WINDOW] permission in order 30 | * to work. 31 | */ 32 | class NeedsDrawOverlayPermissionFeature : NeedsPermissionsFeature { 33 | /** 34 | * Checks whether the app has the [android.Manifest.permission.SYSTEM_ALERT_WINDOW] permission. 35 | */ 36 | override fun hasPermissions(context: Context): Boolean { 37 | return Settings.canDrawOverlays(context) 38 | } 39 | 40 | /** 41 | * Call this method to request the [android.Manifest.permission.SYSTEM_ALERT_WINDOW] 42 | */ 43 | override fun requestPermissions(context: Context, navViewModel: NavViewModel) { 44 | NavigationUtil.openOverlayPermissionsSettings(context) 45 | } 46 | } 47 | 48 | /** 49 | * A feature that needs the [android.Manifest.permission.WRITE_SECURE_SETTINGS] permission in order 50 | * to work. 51 | */ 52 | class NeedsWriteSecureSettingsPermission : NeedsPermissionsFeature { 53 | /** 54 | * Checks whether the app has the [android.Manifest.permission.WRITE_SECURE_SETTINGS] permission. 55 | */ 56 | override fun hasPermissions(context: Context): Boolean { 57 | return context.checkCallingOrSelfPermission("android.permission.WRITE_SECURE_SETTINGS") == PackageManager.PERMISSION_GRANTED 58 | } 59 | 60 | /** 61 | * Call this method to request the [android.Manifest.permission.WRITE_SECURE_SETTINGS] 62 | */ 63 | override fun requestPermissions(context: Context, navViewModel: NavViewModel) { 64 | navViewModel.openPermissionsRequiredScreen( 65 | context.getString(R.string.rootCommand_grantWriteSecuritySettingsPermission), 66 | ) 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/feature_types/ScreenTimeTrackingFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.feature_types 2 | 3 | import androidx.datastore.preferences.core.longPreferencesKey 4 | import com.flx_apps.digitaldetox.data.DataStoreProperty 5 | import java.time.LocalDate 6 | 7 | /** 8 | * This feature can track the screen time of the user under certain conditions. 9 | * Example use cases are tracking the screen time of the user to disable or grayscale apps only 10 | * after a certain amount of time. 11 | */ 12 | interface ScreenTimeTrackingFeature { 13 | /** 14 | * The timestamp when the user did something that should be tracked. 15 | */ 16 | var trackingSinceTimestamp: Long 17 | 18 | /** 19 | * The time the user has already used up their daily screen time. 20 | */ 21 | var usedUpScreenTime: Long 22 | 23 | /** 24 | * The current date. If the date changes, the [usedUpScreenTime] is reset. 25 | */ 26 | var today: LocalDate 27 | 28 | /** 29 | * This method should be called when the user does something that should be tracked, e.g. when 30 | * an app that should be disabled after a certain time is opened. 31 | */ 32 | fun eventuallyStartTracking() 33 | 34 | /** 35 | * Increases the used up screen time by the time since [eventuallyStartTracking] was called. 36 | * 37 | * We track these times very tightly using this approach. We could consider using the 38 | * [UsageStatsProvider] as an alternative. This would require the usage stats permission, 39 | * but would also allow us to track the screen time even if DetoxDroid has been killed in the 40 | * meantime. 41 | * 42 | * Another advantage of the current approach is that we can also track the screen time for apps 43 | * that are installed within the Work Profile (whose usage stats are not available to the 44 | * main profile). 45 | */ 46 | fun eventuallyIncreaseUsedUpScreenTime() 47 | 48 | class Impl(private val featureId: FeatureId) : ScreenTimeTrackingFeature { 49 | override var trackingSinceTimestamp: Long = 0L 50 | override var usedUpScreenTime: Long by DataStoreProperty( 51 | longPreferencesKey("${featureId}_usedUpScreenTime"), 0L 52 | ) 53 | override var today: LocalDate = LocalDate.now() 54 | 55 | override fun eventuallyStartTracking() { 56 | if (trackingSinceTimestamp == 0L) { 57 | // we are not tracking yet, so we start tracking now 58 | trackingSinceTimestamp = System.currentTimeMillis() 59 | } 60 | } 61 | 62 | override fun eventuallyIncreaseUsedUpScreenTime() { 63 | val today = LocalDate.now() 64 | if (today != this.today) { 65 | // the date has changed, so we reset the used up screen time 66 | usedUpScreenTime = 0L 67 | this.today = today 68 | } 69 | if (trackingSinceTimestamp == 0L) return // there is no tracking timestamp 70 | usedUpScreenTime += System.currentTimeMillis() - trackingSinceTimestamp 71 | trackingSinceTimestamp = 0L 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/feature_types/SubscriptionFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.feature_types 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.graphics.Rect 6 | import android.view.accessibility.AccessibilityEvent 7 | 8 | /** 9 | * An interface for features that react to "screen turned off" events. 10 | * @see Intent.ACTION_SCREEN_OFF 11 | */ 12 | interface OnScreenTurnedOffSubscriptionFeature { 13 | /** 14 | * Called when the screen is turned off. 15 | */ 16 | fun onScreenTurnedOff(context: Context?) 17 | } 18 | 19 | /** 20 | * An interface for features that react to app open events. 21 | * @see AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 22 | */ 23 | interface OnAppOpenedSubscriptionFeature { 24 | /** 25 | * Called when an app is opened. 26 | * @param packageName The package name of the app. 27 | * @param accessibilityEvent The accessibility event that triggered the app open. 28 | */ 29 | fun onAppOpened(context: Context, packageName: String, accessibilityEvent: AccessibilityEvent) 30 | } 31 | 32 | /** 33 | * An interface for features that react to scroll events. 34 | * @see AccessibilityEvent.TYPE_VIEW_SCROLLED 35 | */ 36 | interface OnScrollEventSubscriptionFeature { 37 | companion object { 38 | /** 39 | * Calculates a unique ID for the scroll view that triggered the scroll event. 40 | * 41 | * We do not want to use accessibilityEvent.source.hashCode(), because that would generate 42 | * new ids for e.g. different Twitter profile feeds. However, we want to treat different 43 | * Twitter profile feeds as one scroll view though, because they belong to the same 44 | * "infinitely scrolling" behavior triggering UI element. 45 | * 46 | * So instead, we do some elaborate calculation to get a unique ID for the scroll view 47 | * that takes into account the class name, package name, bounds and view id resource name. 48 | * 49 | * @param accessibilityEvent The accessibility event that triggered the scroll event. 50 | */ 51 | fun calculateScrollViewId(accessibilityEvent: AccessibilityEvent): Int { 52 | // we will not use accessibilityEvent.source.hashCode(), because that generates new ids 53 | // for e.g. different Twitter profile feeds - we want to treat different Twitter profile 54 | // feeds as one scroll view though, because they belong to the same "infinitely scrolling" 55 | // behavior triggering UI element 56 | // so instead, we do some elaborate calculation to get a unique ID for the scroll view 57 | // that takes into account the class name, package name, bounds and view id resource name 58 | return accessibilityEvent.className.hashCode() + accessibilityEvent.packageName.hashCode() + accessibilityEvent.source!!.getBoundsInScreen( 59 | Rect() 60 | ).hashCode() + (accessibilityEvent.source!!.viewIdResourceName?.hashCode() ?: 0) 61 | } 62 | } 63 | 64 | /** 65 | * Called when a scroll event is detected. 66 | * @param scrollViewId The (calculated) ID of the scroll view. 67 | * @param scrollViewSize The size of the scroll view, either in pixels or items count 68 | * (depending on which information is available). 69 | * @param accessibilityEvent The accessibility event that triggered the scroll event. 70 | */ 71 | fun onScrollEvent( 72 | context: Context, 73 | scrollViewId: Int, 74 | scrollViewSize: Int, 75 | accessibilityEvent: AccessibilityEvent 76 | ) 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/feature_types/SupportsAppExceptionsFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.feature_types 2 | 3 | import AppExceptionsListSettingsSheet 4 | import androidx.datastore.preferences.core.stringPreferencesKey 5 | import androidx.datastore.preferences.core.stringSetPreferencesKey 6 | import com.flx_apps.digitaldetox.data.DataStoreProperty 7 | import com.flx_apps.digitaldetox.data.DataStorePropertyTransformer 8 | 9 | /** 10 | * The type of the app exception list. 11 | * [AppExceptionListType.NOT_LIST] means, that the apps in the list are NOT affected by the feature. 12 | * [AppExceptionListType.ONLY_LIST] means, that ONLY the apps in the list are ONLY affected by the 13 | * feature. 14 | */ 15 | enum class AppExceptionListType { 16 | NOT_LIST, ONLY_LIST 17 | } 18 | 19 | interface SupportsAppExceptionsFeature { 20 | /** 21 | * Holds the package names of apps that are exceptions for this feature. 22 | */ 23 | var appExceptions: Set 24 | 25 | /** 26 | * Whether the [appExceptions] should be treated as a blocklist or a allowlist. 27 | * @see AppExceptionListType 28 | */ 29 | var appExceptionListType: AppExceptionListType 30 | 31 | /** 32 | * A list of all possible [AppExceptionListType]s. Some features possibly don't need all kinds 33 | * of [AppExceptionListType]s, so they can override this property. For example, the 34 | * [DisableAppsFeature] only supports [AppExceptionListType.ONLY_LIST], as it does not make 35 | * sense to have a list of apps that are *not* blocked. 36 | * 37 | * This property affects the UI of the app exceptions screen, cf. 38 | * [AppExceptionsListSettingsSheet]. 39 | */ 40 | val listTypes: List 41 | get() = listOf( 42 | AppExceptionListType.NOT_LIST, AppExceptionListType.ONLY_LIST 43 | ) 44 | 45 | /** 46 | * The implementation of the [SupportsAppExceptionsFeature] interface. 47 | * @param featureId The id of the feature (usually the simple name of the feature class). 48 | * @param defaultExceptionListType The default [AppExceptionListType] for this feature. 49 | */ 50 | class Impl( 51 | private val featureId: FeatureId, 52 | private val defaultExceptionListType: AppExceptionListType = AppExceptionListType.NOT_LIST 53 | ) : SupportsAppExceptionsFeature { 54 | override var appExceptions: Set by DataStoreProperty( 55 | stringSetPreferencesKey("${featureId}_exceptions"), setOf() 56 | ) 57 | override var appExceptionListType: AppExceptionListType by DataStoreProperty( 58 | stringPreferencesKey("${featureId}_exceptionListType"), 59 | defaultExceptionListType, 60 | dataTransformer = DataStorePropertyTransformer.EnumStorePropertyTransformer( 61 | AppExceptionListType::class.java 62 | ) 63 | ) 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/feature_types/SupportsScheduleFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.feature_types 2 | 3 | import androidx.datastore.preferences.core.stringSetPreferencesKey 4 | import com.flx_apps.digitaldetox.data.DataStoreProperty 5 | import com.flx_apps.digitaldetox.data.DataStorePropertyTransformer 6 | import java.time.DayOfWeek 7 | import java.time.LocalDateTime 8 | import java.time.LocalTime 9 | 10 | /** 11 | * A feature that supports scheduling. This means that the feature can be scheduled to be active 12 | * only at certain times of the day and/or on certain days of the week. 13 | * @see FeatureScheduleRule 14 | * @see isScheduled 15 | */ 16 | interface SupportsScheduleFeature { 17 | /** 18 | * Holds the schedule rules for a feature. 19 | */ 20 | var scheduleRules: Set 21 | 22 | /** 23 | * Returns whether the feature is scheduled at the given date and time. 24 | */ 25 | fun isScheduled(atDateTime: LocalDateTime = LocalDateTime.now()): Boolean 26 | 27 | /** 28 | * The implementation of [SupportsAppExceptionsFeature]. 29 | * @param featureId The [FeatureId] is needed in order to properly 30 | */ 31 | class Impl(private val featureId: FeatureId) : SupportsScheduleFeature { 32 | override var scheduleRules: Set by DataStoreProperty( 33 | stringSetPreferencesKey("${featureId}_scheduleRules"), 34 | setOf(), 35 | dataTransformer = DataStorePropertyTransformer.SetStorePropertyTransformer( 36 | itemFromString = { 37 | FeatureScheduleRule.fromString(it) 38 | }, 39 | itemToString = { it.toString() }) 40 | ) 41 | 42 | override fun isScheduled(atDateTime: LocalDateTime): Boolean { 43 | return scheduleRules.isEmpty() || scheduleRules.any { 44 | it.isActive(atDateTime) 45 | } 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * A rule for when a feature should be active. A rule consists of a time range and a day of the 52 | * week. The feature will be active during the time range on the specified day of the week. If 53 | * multiple rules apply, the feature will be active if at least one of them is active. 54 | * 55 | * For convenience reasons, we will store the rules in a string format in the data store. (Storing 56 | * them in a database would be more efficient, but seems like overkill for this simple use case.) 57 | * Hence, this class also provides a method to convert a rule to a string and vice versa. 58 | * 59 | * @param daysOfWeek The days of the week when the feature should be active. 60 | * @param start The start time of the time range. 61 | * @param end The end time of the time range. 62 | * 63 | * @see FeatureScheduleRule.fromString 64 | * @see FeatureScheduleRule.toString 65 | */ 66 | data class FeatureScheduleRule( 67 | val daysOfWeek: List, val start: LocalTime, val end: LocalTime 68 | ) { 69 | companion object { 70 | /** 71 | * Converts a string to a rule, e.g. "MONDAY|TUESDAY|WEDNESDAY,00:00,08:00" to a rule that 72 | * is active on Monday, Tuesday and Wednesday from 00:00 to 08:00. If the string is invalid, 73 | * null is returned. 74 | * 75 | * @param string The string to convert. 76 | */ 77 | fun fromString(string: String): FeatureScheduleRule? { 78 | kotlin.runCatching { 79 | val parts = string.split(",") 80 | val daysOfWeek = parts[0].takeIf { it.isNotBlank() }?.split("|") 81 | ?.map { DayOfWeek.of(it.toInt()) } ?: emptyList() 82 | val start = LocalTime.parse(parts[1]) 83 | val end = LocalTime.parse(parts[2]) 84 | return FeatureScheduleRule(daysOfWeek, start, end) 85 | } 86 | return null 87 | } 88 | } 89 | 90 | /** 91 | * Whether the rule is currently active. If end is before start, the rule is active from start 92 | * to midnight and from midnight to end (the next day). 93 | */ 94 | fun isActive(atDateTime: LocalDateTime = LocalDateTime.now()): Boolean { 95 | var dayOfWeek = atDateTime.dayOfWeek 96 | val atTime = atDateTime.toLocalTime() 97 | 98 | // copy fromTime and toTime and leave original object alone 99 | var fromTime = start 100 | var toTime = end 101 | 102 | if (toTime.isBefore(fromTime)) { 103 | // we have a rule that spans midnight, e.g. 20:00-04:00 (next day) 104 | if (atTime.isBefore(toTime)) { 105 | // timeOfDay is e.g. 03:00, so set fromTime to 00:00 and imagine dayOfWeek as still yesterday 106 | fromTime = LocalTime.of(0, 0) 107 | dayOfWeek = dayOfWeek.minus(1) 108 | } else if (atTime.isAfter(fromTime)) { 109 | // timeOfDay is e.g. 21:00, so set toTime to 23:59:59 (midnight) 110 | toTime = LocalTime.of(23, 59, 59, 999999999) 111 | } 112 | } 113 | 114 | // the rule is active if the current day of week is in the list of days of week and the 115 | // current time is between fromTime and toTime or fromTime == toTime (then the whole day is 116 | // considered active) 117 | return (daysOfWeek.isEmpty() || daysOfWeek.contains(dayOfWeek)) && ((atTime.isAfter(fromTime) && atTime.isBefore( 118 | toTime 119 | )) || (fromTime == toTime)) 120 | } 121 | 122 | fun copyWith( 123 | daysOfWeek: List? = null, start: LocalTime? = null, end: LocalTime? = null 124 | ): FeatureScheduleRule { 125 | return FeatureScheduleRule( 126 | daysOfWeek ?: this.daysOfWeek, start ?: this.start, end ?: this.end 127 | ) 128 | } 129 | 130 | /** 131 | * Converts the rule to a string, e.g. "MONDAY|TUESDAY|WEDNESDAY,00:00,08:00". 132 | */ 133 | override fun toString(): String { 134 | return "${daysOfWeek.joinToString("|") { it.value.toString() }},$start,$end" 135 | } 136 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/features/+FeaturesProvider.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.features 2 | 3 | import com.flx_apps.digitaldetox.DetoxDroidApplication 4 | import com.flx_apps.digitaldetox.OneMinuteInMs 5 | import com.flx_apps.digitaldetox.feature_types.Feature 6 | import com.flx_apps.digitaldetox.feature_types.OnAppOpenedSubscriptionFeature 7 | import com.flx_apps.digitaldetox.feature_types.OnScreenTurnedOffSubscriptionFeature 8 | import com.flx_apps.digitaldetox.feature_types.OnScrollEventSubscriptionFeature 9 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidAccessibilityService 10 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidState 11 | 12 | /** 13 | * A helper class that provides access to the features of the app. It provides methods to get a 14 | * feature by its id and to get features by specific properties (e.g. all currently active features, 15 | * all features that react to scroll events, etc.) to reduce the number of filter operations. 16 | * 17 | * It is a singleton object that is held in memory as long as the app is running, as it is used 18 | * everywhere in the app. 19 | */ 20 | object FeaturesProvider { 21 | /** 22 | * A list of all features of the app. This list is used to display the features in the UI and to 23 | * iterate over them when an app event occurs. The order of the features in this list is the 24 | * order in which they are displayed in the UI. 25 | */ 26 | val featureList = listOf( 27 | GrayscaleAppsFeature, 28 | DoNotDisturbFeature, 29 | BreakDoomScrollingFeature, 30 | DisableAppsFeature, 31 | PauseButtonFeature 32 | ) 33 | 34 | /** 35 | * A map of all features of the app. This map is used to get a feature by its id. 36 | */ 37 | private val featureMap = featureList.associateBy { it.id } 38 | 39 | /** 40 | * The last time the active features have been reloaded. This is used to reduce the number of 41 | * calls to [Feature.isActive] for performance reasons. 42 | */ 43 | private var lastActiveFeaturesReload = 0L 44 | 45 | /** 46 | * A list of all currently active features. This list is reloaded only every minute to reduce the 47 | * number of calls to [Feature.isActive] for performance reasons. 48 | * @see Feature.isActive 49 | * @see lastActiveFeaturesReload 50 | */ 51 | var activeFeatures = mutableSetOf() 52 | get() { 53 | // reload active features every minute to reduce the number of calls to isActive() 54 | // for performance reasons 55 | if (System.currentTimeMillis() - lastActiveFeaturesReload > OneMinuteInMs) { 56 | val newActiveFeatures = featureList.filter { it.isActive() }.toMutableSet() 57 | if (DetoxDroidAccessibilityService.updateState() != DetoxDroidState.Inactive) { 58 | field.forEach { feature -> 59 | if (!newActiveFeatures.contains(feature)) { 60 | feature.onPause(DetoxDroidApplication.appContext) 61 | } 62 | } 63 | } 64 | field = featureList.filter { it.isActive() }.toMutableSet() 65 | lastActiveFeaturesReload = System.currentTimeMillis() 66 | } 67 | return field 68 | } 69 | 70 | /** 71 | * Forces a reload of the active features. This is useful if the active state of a feature is 72 | * known to have changed (e.g. when the user has changed the schedule), but the active features 73 | * have not been reloaded yet. 74 | */ 75 | fun reloadActiveFeatures() { 76 | lastActiveFeaturesReload = 0L 77 | activeFeatures 78 | } 79 | 80 | /** 81 | * Starts or stops the given feature. This is used when the user toggles the active state of a 82 | * feature in the UI. 83 | * @param feature The feature to start or stop. 84 | * @see Feature.isActive 85 | * @see Feature.onStart 86 | * @see Feature.onPause 87 | */ 88 | fun startOrStopFeature(feature: Feature) { 89 | reloadActiveFeatures() 90 | if (DetoxDroidAccessibilityService.updateState() == DetoxDroidState.Active) { 91 | // if DetoxDroid is running, call onStart() or onPause() for the feature 92 | if (feature.isActive()) feature.onStart(DetoxDroidApplication.appContext) else feature.onPause( 93 | DetoxDroidApplication.appContext 94 | ) 95 | } 96 | } 97 | 98 | /** 99 | * Returns all features that implement the [OnScrollEventSubscriptionFeature] interface. 100 | */ 101 | val onScrollEventFeatures = 102 | featureList.filterIsInstance().toSet() 103 | 104 | /** 105 | * Returns all features that implement the [OnAppOpenedSubscriptionFeature] interface. 106 | */ 107 | val onAppOpenedFeatures = featureList.filterIsInstance().toSet() 108 | 109 | /** 110 | * Returns all features that implement the [OnScreenTurnedOffSubscriptionFeature] interface. 111 | */ 112 | val onScreenTurnedOffFeatures = 113 | featureList.filterIsInstance().toSet() 114 | 115 | /** 116 | * Returns the feature with the given id or null if no feature with this id exists. 117 | * @see Feature.id 118 | */ 119 | fun getFeatureById(id: String): Feature? { 120 | return featureMap[id] 121 | } 122 | } 123 | 124 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/features/DoNotDisturbFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.features 2 | 3 | import android.app.NotificationManager 4 | import android.content.Context 5 | import android.view.accessibility.AccessibilityEvent 6 | import androidx.compose.runtime.Composable 7 | import com.flx_apps.digitaldetox.R 8 | import com.flx_apps.digitaldetox.feature_types.Feature 9 | import com.flx_apps.digitaldetox.feature_types.FeatureTexts 10 | import com.flx_apps.digitaldetox.feature_types.NeedsPermissionsFeature 11 | import com.flx_apps.digitaldetox.feature_types.OnAppOpenedSubscriptionFeature 12 | import com.flx_apps.digitaldetox.feature_types.SupportsScheduleFeature 13 | import com.flx_apps.digitaldetox.ui.screens.feature.do_not_disturb.DoNotDisturbFeatureSettingsSection 14 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavViewModel 15 | import com.flx_apps.digitaldetox.util.NavigationUtil 16 | 17 | val DoNotDisturbFeatureId = Feature.createId(DoNotDisturbFeature::class.java) 18 | 19 | /** 20 | * This feature can enable the zen mode (do not disturb mode) on the device depending on the 21 | * schedule. It is enabled frequently, as the user might have disabled it manually. 22 | */ 23 | object DoNotDisturbFeature : Feature(), OnAppOpenedSubscriptionFeature, NeedsPermissionsFeature, 24 | SupportsScheduleFeature by SupportsScheduleFeature.Impl(DoNotDisturbFeatureId) { 25 | override val texts: FeatureTexts = FeatureTexts( 26 | title = R.string.feature_doNotDisturb, 27 | subtitle = R.string.feature_doNotDisturb_subtitle, 28 | description = R.string.feature_doNotDisturb_description, 29 | ) 30 | override val iconRes: Int = R.drawable.ic_do_not_disturb 31 | override val settingsContent: @Composable () -> Unit = { DoNotDisturbFeatureSettingsSection() } 32 | 33 | /** 34 | * Holds the current state of the zen mode (in order to avoid unnecessary calls to the system). 35 | */ 36 | private var isZenModeEnabled: Boolean = false 37 | 38 | /** 39 | * Holds the state of the zen mode before the feature was activated. 40 | */ 41 | private var wasZenModeEnabledBefore: Boolean = false 42 | 43 | /** 44 | * On start, we check if the zen mode is enabled and save the state. 45 | * Then we enable the zen mode. 46 | */ 47 | override fun onStart(context: Context) { 48 | val notificationManager: NotificationManager = 49 | context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 50 | wasZenModeEnabledBefore = 51 | notificationManager.currentInterruptionFilter == NotificationManager.INTERRUPTION_FILTER_PRIORITY 52 | setZenMode(context, enabled = true, forceSetting = true) 53 | } 54 | 55 | /** 56 | * On pause, we set the zen mode to the state it was before the feature was activated. 57 | */ 58 | override fun onPause(context: Context) { 59 | setZenMode(context, enabled = wasZenModeEnabledBefore, forceSetting = true) 60 | } 61 | 62 | /** 63 | * We use [onAppOpened] to enable the zen mode frequently, as the user might have disabled it 64 | * manually. 65 | */ 66 | override fun onAppOpened( 67 | context: Context, packageName: String, accessibilityEvent: AccessibilityEvent 68 | ) { 69 | setZenMode(context, true) 70 | } 71 | 72 | /** 73 | * Sets the zen mode to the given value. 74 | */ 75 | @JvmStatic 76 | fun setZenMode( 77 | context: Context, enabled: Boolean, forceSetting: Boolean = false 78 | ): Boolean { 79 | // check if nothing has changed 80 | if (isZenModeEnabled == enabled && !forceSetting) return false 81 | 82 | val notificationManager: NotificationManager = 83 | context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 84 | // we don't need to check for permissions (again), as we assume that they are already granted at this point 85 | // as the feature has been activated before 86 | notificationManager.setInterruptionFilter(if (enabled) NotificationManager.INTERRUPTION_FILTER_PRIORITY else NotificationManager.INTERRUPTION_FILTER_ALL) 87 | isZenModeEnabled = enabled 88 | return true 89 | } 90 | 91 | /** 92 | * Checks whether the user has granted the app the permission to change the zen mode. 93 | */ 94 | override fun hasPermissions(context: Context): Boolean { 95 | val notificationManager: NotificationManager = 96 | context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 97 | return notificationManager.isNotificationPolicyAccessGranted 98 | } 99 | 100 | /** 101 | * Opens the system settings to grant the permission to change the zen mode. 102 | */ 103 | override fun requestPermissions(context: Context, navViewModel: NavViewModel) { 104 | NavigationUtil.openDoNotDisturbSystemSettings(context) 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/features/PauseButtonFeature.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.features 2 | 3 | import android.content.Context 4 | import android.view.KeyEvent 5 | import android.widget.Toast 6 | import androidx.compose.runtime.Composable 7 | import androidx.datastore.preferences.core.intPreferencesKey 8 | import androidx.datastore.preferences.core.longPreferencesKey 9 | import com.flx_apps.digitaldetox.R 10 | import com.flx_apps.digitaldetox.data.DataStoreProperty 11 | import com.flx_apps.digitaldetox.feature_types.Feature 12 | import com.flx_apps.digitaldetox.feature_types.FeatureTexts 13 | import com.flx_apps.digitaldetox.features.PauseButtonFeature.hardwareKey 14 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidAccessibilityService 15 | import com.flx_apps.digitaldetox.system_integration.PauseInteractionService 16 | import com.flx_apps.digitaldetox.system_integration.PauseTileService 17 | import com.flx_apps.digitaldetox.ui.screens.feature.pause_button.PauseButtonFeatureSettingsSection 18 | import java.util.concurrent.TimeUnit 19 | 20 | /** 21 | * The [PauseButtonFeature] can be used to pause DetoxDroid for a defined amount of time. It will 22 | * call [Feature.onPause] for all active features and DetoxDroid will ignore all accessibility 23 | * events until the pause is over. 24 | * 25 | * The user can pause DetoxDroid: 26 | * 27 | * - by long-pressing the hardware key defined in [hardwareKey] for 2 seconds (see 28 | * [DetoxDroidAccessibilityService.onKeyEvent], 29 | * 30 | * - by clicking the pause button in the quick settings tile (see [PauseTileService]), or 31 | * 32 | * - by setting DetoxDroid as default assistant app (see [PauseInteractionService]). 33 | */ 34 | object PauseButtonFeature : Feature() { 35 | override val texts: FeatureTexts = FeatureTexts( 36 | title = R.string.feature_pause, 37 | subtitle = R.string.feature_pause_subtitle, 38 | description = R.string.feature_pause_description, 39 | ) 40 | override val iconRes: Int = R.drawable.ic_pause 41 | override val settingsContent: @Composable () -> Unit = { 42 | PauseButtonFeatureSettingsSection() 43 | } 44 | 45 | /** 46 | * The duration of the pause in milliseconds. 47 | */ 48 | var pauseDuration: Long by DataStoreProperty( 49 | longPreferencesKey("${id}_pauseDuration"), TimeUnit.MINUTES.toMillis(3) 50 | ) 51 | 52 | /** 53 | * The minimum time that has to pass between two pauses in milliseconds. 54 | * If the user pauses the app before this time has passed, the pause will be ignored. 55 | * This is useful to prevent the user from pausing the app and immediately unpausing it again 56 | * (and thereby tricking themselves). 57 | */ 58 | var timeBetweenPausesDuration: Long by DataStoreProperty( 59 | longPreferencesKey("${id}_timeBetweenPausesDuration"), TimeUnit.MINUTES.toMillis(0) 60 | ) 61 | 62 | /** 63 | * The hardware key that can be used to pause DetoxDroid by long-pressing it for 2 seconds. 64 | */ 65 | var hardwareKey: Int by DataStoreProperty( 66 | intPreferencesKey("${id}_hardwareKey"), KeyEvent.KEYCODE_UNKNOWN 67 | ) 68 | 69 | /** 70 | * Holds the time until the pause is over. 71 | */ 72 | private var pauseUntil = 0L 73 | 74 | override fun onPause(context: Context) { 75 | pauseUntil = System.currentTimeMillis() + pauseDuration 76 | Toast.makeText( 77 | context, context.getString( 78 | R.string.app_quickSettingsTile_paused, 79 | TimeUnit.MILLISECONDS.toMinutes(pauseDuration) 80 | ), Toast.LENGTH_SHORT 81 | ).show() 82 | } 83 | 84 | fun togglePause(context: Context) { 85 | if (!isActivated) return 86 | if (isPausing()) { 87 | resume() 88 | return 89 | } 90 | if (System.currentTimeMillis() - pauseUntil < timeBetweenPausesDuration) { 91 | val timeUntilNextPauseInMinutes = 92 | TimeUnit.MILLISECONDS.toMinutes(timeBetweenPausesDuration - (System.currentTimeMillis() - pauseUntil)) 93 | // ignore pause 94 | Toast.makeText(context, R.string.app_quickSettingsTile_noPause, Toast.LENGTH_SHORT) 95 | .show() 96 | return 97 | } 98 | pauseFeatures(context) 99 | } 100 | 101 | fun pauseFeatures(context: Context, stop: Boolean = false) { 102 | if (!isActivated && !stop) return 103 | FeaturesProvider.featureList.forEach { 104 | if ((stop && it == this) || !it.isActive()) return@forEach // call onPause() only for active features 105 | it.onPause(context) 106 | } 107 | DetoxDroidAccessibilityService.updateState() 108 | } 109 | 110 | fun resume() { 111 | pauseUntil = 0 112 | DetoxDroidAccessibilityService.instance.takeIf { it != null }?.let { service -> 113 | // call onStart() for all active features if we have an instance of the service 114 | FeaturesProvider.activeFeatures.onEach { it.onStart(service) } 115 | } 116 | DetoxDroidAccessibilityService.updateState() 117 | } 118 | 119 | fun isPausing(): Boolean { 120 | return System.currentTimeMillis() < pauseUntil 121 | } 122 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/system_integration/DetoxDroidDeviceAdminReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.system_integration 2 | 3 | import android.app.admin.DeviceAdminReceiver 4 | import android.app.admin.DevicePolicyManager 5 | import android.content.Context 6 | import android.content.Intent 7 | 8 | class DetoxDroidDeviceAdminReceiver : DeviceAdminReceiver() { 9 | companion object { 10 | fun isGranted(context: Context): Boolean { 11 | return (context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager?)?.isDeviceOwnerApp( 12 | context.packageName 13 | ) == true 14 | } 15 | 16 | fun revokePermission(context: Context) { 17 | (context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager?)?.clearDeviceOwnerApp( 18 | context.packageName 19 | ) 20 | } 21 | } 22 | 23 | override fun onReceive(context: Context, intent: Intent) { 24 | // This method is called when the BroadcastReceiver is receiving an Intent broadcast. 25 | // TODO("DetoxDroidDeviceAdminReceiver.onReceive() is not implemented") 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/system_integration/OverlayService.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.system_integration 2 | 3 | import android.app.ActivityManager 4 | import android.app.AlarmManager 5 | import android.app.PendingIntent 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.graphics.PixelFormat 9 | import android.os.SystemClock 10 | import android.view.Gravity 11 | import android.view.View 12 | import android.view.WindowManager 13 | import androidx.compose.foundation.layout.Box 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.ComposeView 18 | import androidx.lifecycle.LifecycleService 19 | import androidx.lifecycle.setViewTreeLifecycleOwner 20 | import androidx.savedstate.SavedStateRegistry 21 | import androidx.savedstate.SavedStateRegistryController 22 | import androidx.savedstate.SavedStateRegistryOwner 23 | import androidx.savedstate.setViewTreeSavedStateRegistryOwner 24 | 25 | /** 26 | * Service that can display an overlay on top of other apps 27 | * @param content the content to display 28 | */ 29 | abstract class OverlayService(private val overlayContent: OverlayContent) : LifecycleService(), 30 | SavedStateRegistryOwner { 31 | companion object { 32 | /** 33 | * May be used to pass the package name of the app that is currently running in the 34 | * foreground to the overlay service via an intent. 35 | */ 36 | const val EXTRA_RUNNING_APP_PACKAGE_NAME: String = "runningAppPackageName" 37 | } 38 | 39 | private lateinit var savedStateRegistryController: SavedStateRegistryController 40 | override val savedStateRegistry: SavedStateRegistry 41 | get() = savedStateRegistryController.savedStateRegistry 42 | private lateinit var contentView: View 43 | 44 | private lateinit var runningAppPackageName: String 45 | 46 | override fun onCreate() { 47 | super.onCreate() 48 | 49 | // initialize SavedStateRegistry 50 | savedStateRegistryController = SavedStateRegistryController.create(this) 51 | savedStateRegistryController.performAttach() 52 | savedStateRegistryController.performRestore(null) 53 | 54 | // configuration of ComposeView 55 | contentView = ComposeView(this).apply { 56 | alpha = 0f 57 | setViewTreeSavedStateRegistryOwner(this@OverlayService) 58 | setViewTreeLifecycleOwner(this@OverlayService) 59 | setContent { 60 | Box( 61 | modifier = Modifier.fillMaxSize() 62 | ) { 63 | overlayContent.content() 64 | } 65 | } 66 | animate().alpha(1f).duration = 250 67 | } 68 | 69 | // add ComposeView to window 70 | val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager 71 | val params = WindowManager.LayoutParams( 72 | WindowManager.LayoutParams.MATCH_PARENT, 73 | WindowManager.LayoutParams.MATCH_PARENT, 74 | WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, 75 | WindowManager.LayoutParams.FLAG_FULLSCREEN or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, 76 | PixelFormat.TRANSLUCENT 77 | ) 78 | params.gravity = Gravity.RIGHT or Gravity.TOP 79 | windowManager.addView(contentView, params) 80 | } 81 | 82 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 83 | // get the package name of the app that is currently running in the foreground 84 | runningAppPackageName = intent?.getStringExtra(EXTRA_RUNNING_APP_PACKAGE_NAME) ?: "" 85 | return super.onStartCommand(intent, flags, startId) 86 | } 87 | 88 | override fun onDestroy() { 89 | super.onDestroy() 90 | if (this::runningAppPackageName.isInitialized) { 91 | // try to kill the app that was running in the foreground 92 | val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager 93 | activityManager.killBackgroundProcesses(packageName) 94 | } 95 | } 96 | 97 | /** 98 | * Close the overlay, stop the service and go to the home screen 99 | * @param secondsUntilGoToHomeScreen Seconds that have to pass after the overlay closed until 100 | * the user is directed to the home screen. This is for cases where we want to allow the user 101 | * to finish a task before going to the home screen. 102 | */ 103 | fun closeOverlay(secondsUntilGoToHomeScreen: Long = 0) { 104 | val intentToHome = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) 105 | .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } 106 | if (secondsUntilGoToHomeScreen <= 0) { 107 | // go to home screen immediately 108 | startActivity(intentToHome) 109 | } else { 110 | // use the AlarmManager to go to home screen after a certain amount of time 111 | val pendingIntentToHome = PendingIntent.getActivity( 112 | this, 0, intentToHome, PendingIntent.FLAG_UPDATE_CURRENT 113 | ) 114 | val triggerTime = SystemClock.elapsedRealtime() + secondsUntilGoToHomeScreen * 1000 115 | val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager 116 | alarmManager.set(AlarmManager.ELAPSED_REALTIME, triggerTime, pendingIntentToHome) 117 | } 118 | // remove overlay after animation and stop service 119 | contentView.animate().alpha(0f).setDuration(250L).withEndAction { 120 | contentView.visibility = View.GONE 121 | val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager 122 | windowManager.removeView(contentView) 123 | stopSelf() 124 | }.start() 125 | } 126 | } 127 | 128 | /** 129 | * Workaround: 130 | * This is just a wrapper for the content that should be displayed in the overlay. 131 | * If we passed the content directly to the service via the constructor, we occasionally got an 132 | * error, probably due to bugs in the Jetpack Compose library: 133 | * 134 | * > `androidx.compose.runtime.internal.ComposableLambdaImpl cannot be cast to kotlin.jvm.functions.Function0` 135 | * 136 | * @see StackOverflow 137 | */ 138 | class OverlayContent(val content: @Composable () -> Unit) 139 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/system_integration/PauseInteractionService.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.system_integration 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.service.voice.VoiceInteractionSession 6 | import android.service.voice.VoiceInteractionSessionService 7 | import com.flx_apps.digitaldetox.features.PauseButtonFeature 8 | import timber.log.Timber 9 | 10 | /** 11 | * The [PauseInteractionService] is used to launch pauses from the default digital 12 | * assistant shortcut (typically long-pressing the home button). 13 | */ 14 | class PauseInteractionService : VoiceInteractionSessionService() { 15 | override fun onNewSession(args: Bundle?): VoiceInteractionSession { 16 | return PauseInteractionSession(this) 17 | } 18 | } 19 | 20 | /** 21 | * A [VoiceInteractionSession] that just launches a pause. 22 | */ 23 | class PauseInteractionSession(context: Context) : VoiceInteractionSession(context) { 24 | override fun onHandleAssist(state: AssistState) { 25 | Timber.d("onHandleAssist") 26 | PauseButtonFeature.togglePause(context) 27 | super.onHandleAssist(state) 28 | hide() 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/system_integration/PauseTileService.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.system_integration 2 | 3 | import android.service.quicksettings.Tile 4 | import android.service.quicksettings.TileService 5 | import com.flx_apps.digitaldetox.features.PauseButtonFeature 6 | 7 | /** 8 | * The [PauseTileService] is the tile that is shown in the quick settings. It allows the user to 9 | * toggle the pause state of the [DetoxDroidAccessibilityService]. 10 | */ 11 | class PauseTileService : TileService() { 12 | /** 13 | * Called when the user clicks the tile. 14 | * Will toggle the pause state of the [DetoxDroidAccessibilityService]. 15 | */ 16 | override fun onClick() { 17 | super.onClick() 18 | 19 | if (DetoxDroidAccessibilityService.instance == null) { 20 | return 21 | } 22 | 23 | PauseButtonFeature.togglePause(this) 24 | updateTile() 25 | } 26 | 27 | override fun onStartListening() { 28 | super.onStartListening() 29 | updateTile() 30 | } 31 | 32 | /** 33 | * Updates the tile to the current state of the [DetoxDroidAccessibilityService]. 34 | * @see DetoxDroidAccessibilityService 35 | * @see DetoxDroidState 36 | */ 37 | private fun updateTile() { 38 | val state = when (DetoxDroidAccessibilityService.state.value) { 39 | DetoxDroidState.Inactive -> Tile.STATE_UNAVAILABLE 40 | DetoxDroidState.Paused -> Tile.STATE_ACTIVE 41 | DetoxDroidState.Active -> Tile.STATE_INACTIVE 42 | } 43 | 44 | val tile = qsTile 45 | tile.state = state 46 | tile.updateTile() 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/system_integration/ScreenTurnedOffReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.system_integration 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import com.flx_apps.digitaldetox.features.FeaturesProvider 7 | import com.flx_apps.digitaldetox.feature_types.OnScreenTurnedOffSubscriptionFeature 8 | 9 | /** 10 | * A [BroadcastReceiver] that receives the [Intent.ACTION_SCREEN_OFF] event and forwards it to the 11 | * [FeaturesProvider.onScreenTurnedOffFeatures]. 12 | */ 13 | class ScreenTurnedOffReceiver : BroadcastReceiver() { 14 | override fun onReceive(context: Context?, intent: Intent?) { 15 | FeaturesProvider.activeFeatures.intersect(FeaturesProvider.onScreenTurnedOffFeatures) 16 | .forEach { 17 | (it as OnScreenTurnedOffSubscriptionFeature).onScreenTurnedOff(context) 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/system_integration/UsageStatsProvider.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.system_integration 2 | 3 | import android.app.usage.UsageStats 4 | import android.app.usage.UsageStatsManager 5 | import android.content.Context 6 | import com.flx_apps.digitaldetox.DetoxDroidApplication 7 | import com.flx_apps.digitaldetox.TenSecondsInMs 8 | import java.time.LocalDate 9 | import java.time.ZoneId 10 | 11 | /** 12 | * A singleton that provides access to the [UsageStatsManager] and caches the usage stats for the 13 | * current day. This is used, for example, to determine the screen time of the current day. 14 | */ 15 | object UsageStatsProvider { 16 | /** 17 | * The timestamp of the last refresh of [usageStatsToday]. This is used to cache the usage stats 18 | * for one minute. 19 | */ 20 | private var usageStatsTodayLastRefresh = 0L 21 | 22 | /** 23 | * Returns the usage stats for the current day. This list is cached for one minute to reduce 24 | * the number of calls to the system service. 25 | */ 26 | var usageStatsToday: Map = mapOf() 27 | get() { 28 | val now = System.currentTimeMillis() 29 | if (now - usageStatsTodayLastRefresh > TenSecondsInMs) { 30 | // retrieve usage stats for the current day 31 | val usageStatsManager = DetoxDroidApplication.appContext.getSystemService( 32 | Context.USAGE_STATS_SERVICE 33 | ) as UsageStatsManager 34 | val dayBeginningMs = 35 | LocalDate.now().atStartOfDay().atZone(ZoneId.systemDefault()).toInstant() 36 | .toEpochMilli() 37 | field = usageStatsManager.queryUsageStats( 38 | UsageStatsManager.INTERVAL_DAILY, dayBeginningMs, now 39 | ).filter { 40 | // filter out apps that were not used today 41 | it.firstTimeStamp > dayBeginningMs && it.totalTimeInForeground > 0 42 | }.groupingBy { 43 | // group by package name... 44 | it.packageName 45 | }.aggregate { _, accumulator, element, first -> 46 | // ... and sum up the usage stats for each package name 47 | if (first) element else accumulator!!.apply { add(element) } 48 | } 49 | usageStatsTodayLastRefresh = now 50 | } 51 | return field 52 | } 53 | 54 | /** 55 | * Returns the usage stats for the current day without consulting the cache. 56 | */ 57 | fun getUpdatedUsageStatsToday(): Map { 58 | usageStatsTodayLastRefresh = 0L 59 | return usageStatsToday 60 | } 61 | 62 | /** 63 | * Returns the screen time of the given apps in milliseconds. 64 | */ 65 | fun getScreenTimeForApps(apps: List): Long { 66 | return apps.sumOf { usageStatsToday[it]?.totalTimeInForeground ?: 0L } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/FeatureScreen.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.ArrowBack 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.IconButton 11 | import androidx.compose.material3.LargeTopAppBar 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Scaffold 14 | import androidx.compose.material3.SnackbarHost 15 | import androidx.compose.material3.SnackbarHostState 16 | import androidx.compose.material3.SnackbarResult 17 | import androidx.compose.material3.Switch 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.collectAsState 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.res.stringResource 24 | import androidx.compose.ui.unit.dp 25 | import androidx.lifecycle.viewmodel.compose.viewModel 26 | import com.flx_apps.digitaldetox.MainActivity 27 | import com.flx_apps.digitaldetox.R 28 | import com.flx_apps.digitaldetox.feature_types.Feature 29 | import com.flx_apps.digitaldetox.feature_types.FeatureId 30 | import com.flx_apps.digitaldetox.feature_types.NeedsPermissionsFeature 31 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavViewModel 32 | import com.flx_apps.digitaldetox.ui.widgets.InfoCard 33 | 34 | /** 35 | * A singleton that provides the snackbar host state for the feature screen and its children. 36 | */ 37 | object FeatureScreenSnackbarStateProvider { 38 | val snackbarState: SnackbarHostState by lazy { SnackbarHostState() } 39 | } 40 | 41 | /** 42 | * The screen that shows the details of a feature. It contains a top app bar with a back button and 43 | * a switch to toggle the active state of the feature. The content of the screen is provided by the 44 | * feature itself. 45 | * @param featureId The id of the feature to show. 46 | * @see [Feature.settingsContent] 47 | */ 48 | @OptIn(ExperimentalMaterial3Api::class) 49 | @Composable 50 | fun FeatureScreen( 51 | featureId: FeatureId, 52 | featureViewModel: FeatureViewModel = FeatureViewModel.withFeatureId(featureId), 53 | ) { // back pressed dispatcher is used to handle back button presses 54 | val feature = featureViewModel.feature 55 | val snackbarHostState = FeatureScreenSnackbarStateProvider.snackbarState 56 | val onBackPressedDispatcher = (LocalContext.current as MainActivity).onBackPressedDispatcher 57 | Scaffold(snackbarHost = { 58 | SnackbarHost(hostState = snackbarHostState) 59 | }, topBar = { 60 | LargeTopAppBar(navigationIcon = { // Back button 61 | IconButton(onClick = { 62 | onBackPressedDispatcher.onBackPressed() 63 | }) { 64 | Icon( 65 | imageVector = Icons.Default.ArrowBack, contentDescription = "Back" 66 | ) 67 | } 68 | }, title = { 69 | Column { 70 | Text(stringResource(id = feature.texts.title)) 71 | Text( 72 | stringResource(id = feature.texts.subtitle), 73 | style = MaterialTheme.typography.bodySmall 74 | ) 75 | } 76 | }, actions = { 77 | FeatureActivationSwitch() 78 | }) 79 | }) { 80 | LazyColumn(modifier = Modifier.padding(it)) { 81 | item { 82 | FeatureScreenContent(feature) 83 | } 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * A switch to toggle the active state of the feature. If the feature needs permissions to be 90 | * activated, a snackbar is shown to request the permissions and the state is not toggled. 91 | */ 92 | @Composable 93 | fun FeatureActivationSwitch( 94 | featureViewModel: FeatureViewModel = viewModel(), 95 | navViewModel: NavViewModel = NavViewModel.navViewModel() 96 | ) { 97 | val context = LocalContext.current 98 | Switch(modifier = Modifier.padding(end = 8.dp), 99 | checked = featureViewModel.featureIsActive.collectAsState().value, 100 | onCheckedChange = { 101 | if (featureViewModel.toggleFeatureActive() == null) { 102 | featureViewModel.showSnackbar(message = context.getString(R.string.action_requestPermissions), 103 | actionLabel = context.getString(R.string.action_go), 104 | onResult = { snackbarResult -> 105 | if (snackbarResult == SnackbarResult.ActionPerformed) { 106 | (featureViewModel.feature as NeedsPermissionsFeature).requestPermissions( 107 | context, navViewModel 108 | ) 109 | } 110 | }) 111 | } 112 | }) 113 | } 114 | 115 | /** 116 | * The content of the feature screen. It contains a description of the feature and the settings 117 | * content provided by the feature. 118 | * @see [Feature.texts] 119 | * @see [Feature.settingsContent] 120 | */ 121 | @Composable 122 | fun FeatureScreenContent(feature: Feature) { 123 | InfoCard(infoText = stringResource(id = feature.texts.description)) 124 | feature.settingsContent() 125 | } 126 | 127 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/FeatureViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature 2 | 3 | import android.app.Application 4 | import androidx.compose.material3.SnackbarDuration 5 | import androidx.compose.material3.SnackbarResult 6 | import androidx.compose.runtime.Composable 7 | import androidx.core.os.bundleOf 8 | import androidx.lifecycle.AndroidViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import com.flx_apps.digitaldetox.feature_types.Feature 11 | import com.flx_apps.digitaldetox.feature_types.FeatureId 12 | import com.flx_apps.digitaldetox.feature_types.NeedsPermissionsFeature 13 | import com.flx_apps.digitaldetox.features.FeaturesProvider 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import dev.olshevski.navigation.reimagined.hilt.hiltViewModel 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.StateFlow 18 | import kotlinx.coroutines.launch 19 | import timber.log.Timber 20 | import javax.inject.Inject 21 | 22 | /** 23 | * A factory for the view model of the feature screen. It is used to pass the feature ID to the 24 | * view model. 25 | */ 26 | abstract class FeatureViewModelFactory { 27 | @Composable 28 | inline fun withFeatureId(featureId: FeatureId): T { 29 | Timber.d("withFeatureId: $featureId") 30 | return hiltViewModel(defaultArguments = bundleOf("featureId" to featureId)) 31 | } 32 | } 33 | 34 | /** 35 | * The view model for the feature screen. It contains the feature and provides methods to toggle 36 | * the active state of the feature. 37 | */ 38 | @HiltViewModel 39 | open class FeatureViewModel @Inject constructor( 40 | private val application: Application, savedStateHandle: androidx.lifecycle.SavedStateHandle 41 | ) : AndroidViewModel(application) { 42 | companion object : FeatureViewModelFactory() 43 | 44 | init { 45 | Timber.d("savedStateHandle: $savedStateHandle") 46 | } 47 | 48 | private val featureId: String = savedStateHandle["featureId"]!! 49 | val feature: Feature = FeaturesProvider.getFeatureById(featureId)!! 50 | 51 | private val _featureIsActive = MutableStateFlow(feature.isActivated) 52 | val featureIsActive: StateFlow = _featureIsActive 53 | 54 | private val _snackbarState = FeatureScreenSnackbarStateProvider.snackbarState 55 | 56 | /** 57 | * Checks if the feature needs permissions to be activated and if the app has the permissions. 58 | * @return True if the feature needs permissions and the app has not the permissions, false 59 | * otherwise. 60 | */ 61 | fun activationNeedsPermission(): Boolean { 62 | return feature is NeedsPermissionsFeature && !feature.hasPermissions(application) 63 | } 64 | 65 | /** 66 | * Toggles the active state of the feature. 67 | * @return The new active state of the feature or null if the necessary permissions are missing 68 | */ 69 | internal fun toggleFeatureActive(): Boolean? { 70 | if (activationNeedsPermission()) { 71 | return null 72 | } 73 | feature.isActivated = !feature.isActivated 74 | _featureIsActive.value = feature.isActivated 75 | FeaturesProvider.startOrStopFeature(feature) 76 | return feature.isActivated 77 | } 78 | 79 | fun showSnackbar( 80 | message: String, 81 | actionLabel: String? = null, 82 | duration: SnackbarDuration = SnackbarDuration.Indefinite, 83 | onResult: (SnackbarResult) -> Unit = {} 84 | ) { 85 | viewModelScope.launch { 86 | val result = _snackbarState.showSnackbar( 87 | message = message, actionLabel = actionLabel, duration = duration 88 | ) 89 | onResult(result) 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/OpenAppExceptionsTile.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature 2 | 3 | import androidx.compose.foundation.layout.size 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.KeyboardArrowRight 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.res.vectorResource 13 | import androidx.compose.ui.unit.dp 14 | import androidx.lifecycle.viewmodel.compose.viewModel 15 | import com.flx_apps.digitaldetox.MainActivity 16 | import com.flx_apps.digitaldetox.R 17 | import com.flx_apps.digitaldetox.feature_types.AppExceptionListType 18 | import com.flx_apps.digitaldetox.feature_types.SupportsAppExceptionsFeature 19 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavViewModel 20 | import com.flx_apps.digitaldetox.ui.widgets.SimpleListTile 21 | 22 | /** 23 | * A tile that opens the app exceptions screen 24 | * @param titleText A custom title text for the tile (a default is provided) 25 | * @param subtitleText A custom subtitle text for the tile (a default is provided) 26 | */ 27 | @Composable 28 | fun OpenAppExceptionsTile( 29 | featureViewModel: FeatureViewModel = viewModel(), 30 | navViewModel: NavViewModel = viewModel(viewModelStoreOwner = LocalContext.current as MainActivity), 31 | titleText: String = stringResource(id = R.string.feature_settings_exceptions), 32 | subtitleText: String? = null, 33 | ) { 34 | val feature = featureViewModel.feature as SupportsAppExceptionsFeature 35 | SimpleListTile(titleText = titleText, subtitleText = subtitleText ?: stringResource( 36 | id = if (feature.appExceptionListType == AppExceptionListType.NOT_LIST) R.string.feature_settings_exceptions__notListed 37 | else R.string.feature_settings_exceptions__onlyListed, feature.appExceptions.size 38 | ), trailing = { 39 | Icon( 40 | imageVector = Icons.Default.KeyboardArrowRight, 41 | contentDescription = "Manage Exceptions", 42 | modifier = Modifier.size(24.dp) 43 | ) 44 | }, onClick = { 45 | navViewModel.openAppExceptionsScreen() 46 | }, leadingIcon = ImageVector.vectorResource(id = R.drawable.ic_app_exceptions) 47 | ) 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/OpenScheduleTile.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature 2 | 3 | import androidx.compose.foundation.layout.size 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.EditCalendar 6 | import androidx.compose.material.icons.filled.KeyboardArrowRight 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import androidx.lifecycle.viewmodel.compose.viewModel 14 | import com.flx_apps.digitaldetox.MainActivity 15 | import com.flx_apps.digitaldetox.R 16 | import com.flx_apps.digitaldetox.feature_types.SupportsScheduleFeature 17 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavViewModel 18 | import com.flx_apps.digitaldetox.ui.widgets.SimpleListTile 19 | 20 | /** 21 | * A tile that opens the schedule screen 22 | */ 23 | @Composable 24 | fun OpenScheduleTile( 25 | featureViewModel: FeatureViewModel = viewModel(), 26 | navViewModel: NavViewModel = viewModel(viewModelStoreOwner = LocalContext.current as MainActivity) 27 | ) { 28 | val rulesCount = (featureViewModel.feature as SupportsScheduleFeature).scheduleRules.size 29 | SimpleListTile(titleText = stringResource(id = R.string.feature_settings_schedule), 30 | subtitleText = if (rulesCount == 0) stringResource(id = R.string.feature_settings_schedule_hint_activeAllTheTime) 31 | else stringResource( 32 | id = R.string.feature_settings_schedule_hint, rulesCount 33 | ), 34 | trailing = { 35 | Icon( 36 | imageVector = Icons.Default.KeyboardArrowRight, 37 | contentDescription = "Manage Schedule", 38 | modifier = Modifier.size(24.dp) 39 | ) 40 | }, 41 | leadingIcon = Icons.Default.EditCalendar, 42 | onClick = { 43 | navViewModel.openFeatureScheduleScreen() 44 | }) 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/break_doom_scrolling/BreakDoomScrollingFeatureSettingsSection.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.break_doom_scrolling 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Timelapse 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.collectAsState 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.lifecycle.viewmodel.compose.viewModel 11 | import com.flx_apps.digitaldetox.R 12 | import com.flx_apps.digitaldetox.ui.screens.feature.OpenAppExceptionsTile 13 | import com.flx_apps.digitaldetox.ui.screens.feature.OpenScheduleTile 14 | import com.flx_apps.digitaldetox.ui.widgets.NumberPickerDialog 15 | import com.flx_apps.digitaldetox.ui.widgets.SimpleListTile 16 | 17 | /** 18 | * The UI for the break doom scrolling feature settings section. 19 | */ 20 | @Composable 21 | fun BreakDoomScrollingFeatureSettingsSection( 22 | viewModel: BreakDoomScrollingFeatureSettingsViewModel = viewModel() 23 | ) { 24 | OpenAppExceptionsTile() 25 | OpenScheduleTile() 26 | 27 | val timeUntilWarning = viewModel.timeUntilWarning.collectAsState().value 28 | if (viewModel.showTimeUntilWarningNumberPickerDialog.collectAsState().value) { 29 | val context = LocalContext.current 30 | NumberPickerDialog( 31 | titleText = stringResource(id = R.string.feature_doomScrolling_timeUntilWarning), 32 | initialValue = timeUntilWarning, 33 | onValueSelected = { 34 | viewModel.setTimeUntilWarning(it) 35 | }, 36 | onDismissRequest = { 37 | viewModel.setTimeUntilNumberPickerDialogVisible(false) 38 | }, 39 | label = { 40 | context.getString(R.string.time__minutes, it) 41 | }, 42 | range = 1..60 43 | ) 44 | } 45 | 46 | SimpleListTile( 47 | titleText = stringResource(id = R.string.feature_doomScrolling_timeUntilWarning), 48 | subtitleText = stringResource(id = R.string.feature_doomScrolling_timeUntilWarning_description), 49 | trailing = { 50 | Text( 51 | text = stringResource( 52 | id = R.string.time__minutes, viewModel.timeUntilWarning.collectAsState().value 53 | ) 54 | ) 55 | }, 56 | onClick = { 57 | viewModel.setTimeUntilNumberPickerDialogVisible(true) 58 | }, 59 | leadingIcon = Icons.Default.Timelapse 60 | ) 61 | } 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/break_doom_scrolling/BreakDoomScrollingFeatureSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.break_doom_scrolling 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.flx_apps.digitaldetox.features.BreakDoomScrollingFeature 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import java.util.concurrent.TimeUnit 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class BreakDoomScrollingFeatureSettingsViewModel @Inject constructor() : ViewModel() { 13 | private val _timeUntilWarning: MutableStateFlow = MutableStateFlow( 14 | TimeUnit.MILLISECONDS.toMinutes(BreakDoomScrollingFeature.timeUntilWarning).toInt() 15 | ) 16 | val timeUntilWarning: StateFlow = _timeUntilWarning 17 | 18 | private val _showTimeUntilWarningNumberPickerDialog: MutableStateFlow = 19 | MutableStateFlow(false) 20 | val showTimeUntilWarningNumberPickerDialog: StateFlow = 21 | _showTimeUntilWarningNumberPickerDialog 22 | 23 | /** 24 | * Shows/hides the time until warning number picker dialog. 25 | */ 26 | fun setTimeUntilNumberPickerDialogVisible(visible: Boolean) { 27 | _showTimeUntilWarningNumberPickerDialog.value = visible 28 | } 29 | 30 | /** 31 | * Sets the time until the warning is shown. 32 | * @param timeInMinutes the time in minutes 33 | */ 34 | fun setTimeUntilWarning(timeInMinutes: Int) { 35 | _timeUntilWarning.value = timeInMinutes 36 | BreakDoomScrollingFeature.timeUntilWarning = 37 | TimeUnit.MINUTES.toMillis(timeInMinutes.toLong()) 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/break_doom_scrolling/BreakDoomScrollingOverlay.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.break_doom_scrolling 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.OutlinedButton 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.scale 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.res.painterResource 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.tooling.preview.Preview 22 | import androidx.compose.ui.unit.dp 23 | import com.flx_apps.digitaldetox.R 24 | import com.flx_apps.digitaldetox.system_integration.OverlayContent 25 | import com.flx_apps.digitaldetox.system_integration.OverlayService 26 | import com.flx_apps.digitaldetox.ui.theme.DetoxDroidTheme 27 | 28 | /** 29 | * The service that shows the warning screen when the user is caught "doomscrolling". It is an 30 | * [OverlayService] that shows the [BreakDoomScrollingOverlay]. 31 | * 32 | * The service is started by [BreakDoomScrollingFeature.onScrollEvent], when certain conditions 33 | * are met. 34 | */ 35 | class BreakDoomScrollingOverlayService : 36 | OverlayService(OverlayContent { BreakDoomScrollingOverlay() }) 37 | 38 | /** 39 | * The warning screen UI that is shown when the user is caught "doomscrolling". It provides a 40 | * brief "warning message" and a button to go to the home screen. 41 | */ 42 | @Preview 43 | @Composable 44 | fun BreakDoomScrollingOverlay() { 45 | val context = androidx.compose.ui.platform.LocalContext.current 46 | DetoxDroidTheme(darkTheme = true) { 47 | Column( 48 | modifier = Modifier 49 | .fillMaxSize() 50 | .background(Color.Black.copy(alpha = 0.9f)) 51 | .padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally 52 | ) { 53 | Spacer(modifier = Modifier.weight(2f)) 54 | Text( 55 | text = stringResource(id = R.string.infiniteScroll_warning_title), 56 | style = MaterialTheme.typography.displayLarge, 57 | color = Color.White, 58 | ) 59 | Text( 60 | text = stringResource(id = R.string.infiniteScroll_warning_message), 61 | style = MaterialTheme.typography.titleLarge, 62 | textAlign = TextAlign.Center, 63 | color = Color.White, 64 | modifier = Modifier.padding(vertical = 32.dp) 65 | ) 66 | OutlinedButton(modifier = Modifier 67 | .padding(top = 16.dp) 68 | .scale(1.5f), onClick = { 69 | (context as OverlayService).closeOverlay() 70 | }) { 71 | Text(text = stringResource(id = R.string.infiniteScroll_warning_exit)) 72 | } 73 | Spacer(modifier = Modifier.weight(1f)) 74 | Image( 75 | painter = painterResource(id = R.drawable.ic_launcher_foreground_cropped), 76 | contentDescription = "Logo", 77 | modifier = Modifier.size(196.dp) 78 | ) 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/disable_apps/AppDisabledOverlay.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.disable_apps 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.OutlinedButton 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.scale 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.res.painterResource 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.tooling.preview.Preview 22 | import androidx.compose.ui.unit.dp 23 | import com.flx_apps.digitaldetox.R 24 | import com.flx_apps.digitaldetox.system_integration.OverlayContent 25 | import com.flx_apps.digitaldetox.system_integration.OverlayService 26 | import com.flx_apps.digitaldetox.ui.theme.DetoxDroidTheme 27 | 28 | class AppDisabledOverlayService : OverlayService(OverlayContent { AppDisabledOverlay() }) 29 | 30 | /** 31 | * The overlay that is displayed when the user tries to open a disabled app. 32 | * When closed, the user will be redirected to the home screen. 33 | */ 34 | @Preview 35 | @Composable 36 | fun AppDisabledOverlay() { 37 | val context = androidx.compose.ui.platform.LocalContext.current 38 | DetoxDroidTheme(darkTheme = true) { 39 | Column( 40 | modifier = Modifier 41 | .fillMaxSize() 42 | .background(Color.Black.copy(alpha = 0.9f)) 43 | .padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally 44 | ) { 45 | Spacer(modifier = Modifier.weight(2f)) 46 | Text( 47 | text = stringResource(id = R.string.feature_disableApps_overlay_title), 48 | style = MaterialTheme.typography.displayLarge, 49 | color = Color.White, 50 | modifier = Modifier.padding(vertical = 16.dp) 51 | ) 52 | Text( 53 | text = stringResource(id = R.string.feature_disableApps_overlay_message), 54 | style = MaterialTheme.typography.titleLarge, 55 | textAlign = TextAlign.Center, 56 | color = Color.White, 57 | ) 58 | Text( 59 | text = stringResource(id = R.string.feature_disableApps_overlay_message2), 60 | style = MaterialTheme.typography.titleMedium, 61 | textAlign = TextAlign.Center, 62 | color = Color.White.copy(alpha = 0.8f), 63 | modifier = Modifier.padding(vertical = 32.dp) 64 | ) 65 | OutlinedButton(modifier = Modifier 66 | .padding(top = 16.dp) 67 | .scale(1.5f), onClick = { 68 | (context as OverlayService).closeOverlay() 69 | }) { 70 | Text(text = stringResource(id = R.string.action_close)) 71 | } 72 | Spacer(modifier = Modifier.weight(1f)) 73 | Image( 74 | painter = painterResource(id = R.drawable.ic_launcher_foreground_cropped), 75 | contentDescription = "Logo", 76 | modifier = Modifier.size(196.dp) 77 | ) 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/disable_apps/DisableAppsFeatureSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.disable_apps 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.AndroidViewModel 5 | import com.flx_apps.digitaldetox.features.DisableAppsFeature 6 | import com.flx_apps.digitaldetox.features.DisableAppsMode 7 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidDeviceAdminReceiver 8 | import com.flx_apps.digitaldetox.ui.screens.feature.FeatureViewModelFactory 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.asStateFlow 12 | import timber.log.Timber 13 | import java.util.concurrent.TimeUnit 14 | import javax.inject.Inject 15 | 16 | /** 17 | * The view model for the [DisableAppsFeatureSettingsSection]. 18 | */ 19 | @HiltViewModel 20 | class DisableAppsFeatureSettingsViewModel @Inject constructor(application: Application) : 21 | AndroidViewModel(application) { 22 | companion object : FeatureViewModelFactory() 23 | 24 | private val _operationMode = MutableStateFlow(DisableAppsFeature.operationMode) 25 | val operationMode = _operationMode.asStateFlow() 26 | 27 | /** 28 | * The allowed daily screen time in minutes. 29 | * @see [DisableAppsFeature.allowedDailyScreenTime] 30 | */ 31 | private val _allowedDailyScreenTime = 32 | MutableStateFlow(TimeUnit.MILLISECONDS.toMinutes(DisableAppsFeature.allowedDailyScreenTime)) 33 | val allowedDailyTime = _allowedDailyScreenTime.asStateFlow() 34 | 35 | /** 36 | * Whether the daily screen time picker dialog is visible. 37 | */ 38 | private val _dailyScreenTimePickerDialogVisible = MutableStateFlow(false) 39 | val dailyScreenTimePickerDialogVisible = _dailyScreenTimePickerDialogVisible.asStateFlow() 40 | 41 | /** 42 | * Changes the operation mode of the feature. 43 | * @param mode The new operation mode. 44 | * @return True if the operation mode was changed, false otherwise. 45 | * @see DisableAppsMode 46 | */ 47 | fun changeOperationMode(mode: DisableAppsMode): Boolean { 48 | Timber.d("Changing operation mode to $mode") 49 | if (mode == DisableAppsFeature.operationMode) return true 50 | val app = getApplication() 51 | if (mode == DisableAppsMode.DEACTIVATE && !DetoxDroidDeviceAdminReceiver.isGranted(app)) { 52 | return false 53 | } else if (DisableAppsFeature.operationMode == DisableAppsMode.DEACTIVATE && mode == DisableAppsMode.BLOCK) { 54 | // we eventually need to reactivate the apps 55 | DisableAppsFeature.setAppsDeactivated(app, false, forceOperation = true) 56 | } 57 | DisableAppsFeature.operationMode = mode 58 | _operationMode.value = mode 59 | return true 60 | } 61 | 62 | /** 63 | * Sets the visibility of the daily screen time picker dialog. 64 | */ 65 | fun setShowDailyScreenTimePickerDialog(visible: Boolean) { 66 | _dailyScreenTimePickerDialogVisible.value = visible 67 | } 68 | 69 | /** 70 | * Sets the allowed daily screen time. 71 | * @param minutes The allowed daily screen time in minutes. 72 | */ 73 | fun setAllowedDailyScreenTime(minutes: Long) { 74 | DisableAppsFeature.allowedDailyScreenTime = TimeUnit.MINUTES.toMillis(minutes) 75 | _allowedDailyScreenTime.value = minutes 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/disable_apps/OpenDisabledAppsTile.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.disable_apps 2 | 3 | import ManageAppExceptionsScreen 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.res.stringResource 6 | import com.flx_apps.digitaldetox.R 7 | import com.flx_apps.digitaldetox.features.DisableAppsFeature 8 | import com.flx_apps.digitaldetox.ui.screens.feature.OpenAppExceptionsTile 9 | 10 | /** 11 | * Represents the list tile that opens the [ManageAppExceptionsScreen] for the [DisableAppsFeature]. 12 | */ 13 | @Composable 14 | fun ManageDisabledAppsListTile() { 15 | OpenAppExceptionsTile( 16 | titleText = stringResource(id = R.string.feature_disableApps_manage), 17 | subtitleText = stringResource( 18 | id = R.string.feature_disableApps_manage__defined, DisableAppsFeature.appExceptions.size 19 | ), 20 | ) 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/do_not_disturb/DoNotDisturbFeatureSettingsSection.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.do_not_disturb 2 | 3 | import androidx.compose.material.Icon 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.KeyboardArrowRight 6 | import androidx.compose.material.icons.filled.Settings 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.lifecycle.viewmodel.compose.viewModel 10 | import com.flx_apps.digitaldetox.R 11 | import com.flx_apps.digitaldetox.ui.screens.feature.OpenScheduleTile 12 | import com.flx_apps.digitaldetox.ui.widgets.SimpleListTile 13 | 14 | @Composable 15 | fun DoNotDisturbFeatureSettingsSection( 16 | viewModel: DoNotDisturbFeatureSettingsViewModel = viewModel() 17 | ) { 18 | OpenScheduleTile() 19 | SimpleListTile( 20 | titleText = stringResource(id = R.string.feature_doNotDisturb_systemSettings), 21 | subtitleText = stringResource(id = R.string.feature_doNotDisturb_systemSettings_description), 22 | trailing = { 23 | Icon(imageVector = Icons.Default.KeyboardArrowRight, contentDescription = null) 24 | }, 25 | onClick = { viewModel.openDoNotDisturbSystemSettings() }, 26 | leadingIcon = Icons.Default.Settings 27 | ) 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/do_not_disturb/DoNotDisturbFeatureSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.do_not_disturb 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import androidx.lifecycle.AndroidViewModel 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import javax.inject.Inject 8 | 9 | @HiltViewModel 10 | class DoNotDisturbFeatureSettingsViewModel @Inject constructor(application: Application) : 11 | AndroidViewModel(application) { 12 | /** 13 | * Opens the system settings for the do not disturb feature. 14 | */ 15 | fun openDoNotDisturbSystemSettings() { 16 | val intent = Intent("android.settings.ZEN_MODE_PRIORITY_SETTINGS").apply { 17 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 18 | } 19 | getApplication().startActivity(intent) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/grayscale_apps/GrayscaleAppsFeatureSettingsSection.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.grayscale_apps 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.BrightnessLow 8 | import androidx.compose.material.icons.filled.ColorLens 9 | import androidx.compose.material.icons.filled.Fullscreen 10 | import androidx.compose.material3.Checkbox 11 | import androidx.compose.material3.SnackbarResult 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.collectAsState 14 | import androidx.compose.runtime.rememberCoroutineScope 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.unit.dp 19 | import androidx.lifecycle.viewmodel.compose.viewModel 20 | import com.flx_apps.digitaldetox.R 21 | import com.flx_apps.digitaldetox.features.GrayscaleAppsFeature 22 | import com.flx_apps.digitaldetox.ui.screens.feature.FeatureScreenSnackbarStateProvider 23 | import com.flx_apps.digitaldetox.ui.screens.feature.OpenAppExceptionsTile 24 | import com.flx_apps.digitaldetox.ui.screens.feature.OpenScheduleTile 25 | import com.flx_apps.digitaldetox.ui.theme.labelVerySmall 26 | import com.flx_apps.digitaldetox.ui.widgets.NumberPickerDialog 27 | import com.flx_apps.digitaldetox.ui.widgets.SimpleListTile 28 | import com.flx_apps.digitaldetox.util.NavigationUtil 29 | import com.flx_apps.digitaldetox.util.toHrMinString 30 | import kotlinx.coroutines.launch 31 | import kotlin.time.Duration.Companion.milliseconds 32 | import kotlin.time.Duration.Companion.minutes 33 | 34 | /** 35 | * A tile for the grayscale apps feature settings screen. 36 | */ 37 | @Composable 38 | fun GrayscaleAppsFeatureSettingsSection( 39 | viewModel: GrayscaleAppsFeatureSettingsViewModel = viewModel() 40 | ) { 41 | OpenAppExceptionsTile() 42 | OpenScheduleTile() 43 | ExtraDimTile() 44 | IgnoreFullScreenAppsTile() 45 | AllowedDailyColorScreenTimeTile() 46 | } 47 | 48 | /** 49 | * The UI element for toggling the extra dim setting. 50 | */ 51 | @Composable 52 | fun ExtraDimTile(viewModel: GrayscaleAppsFeatureSettingsViewModel = viewModel()) { 53 | SimpleListTile( 54 | titleText = stringResource(id = R.string.feature_grayscale_extraDim), 55 | subtitleText = stringResource(id = R.string.feature_grayscale_extraDim_description_description), 56 | trailing = { 57 | Checkbox(checked = viewModel.extraDimActivated.collectAsState().value, 58 | onCheckedChange = { 59 | viewModel.toggleExtraDim() 60 | }) 61 | }, 62 | leadingIcon = Icons.Default.BrightnessLow 63 | ) 64 | } 65 | 66 | /** 67 | * The UI element for toggling the ignore non full screen apps setting. 68 | */ 69 | @Composable 70 | fun IgnoreFullScreenAppsTile(viewModel: GrayscaleAppsFeatureSettingsViewModel = viewModel()) { 71 | SimpleListTile( 72 | titleText = stringResource(id = R.string.feature_grayscale_ignoreNonFullScreen), 73 | subtitleText = stringResource(id = R.string.feature_grayscale_ignoreNonFullScreen_description), 74 | trailing = { 75 | Checkbox(checked = viewModel.ignoreNonFullScreenApps.collectAsState().value, 76 | onCheckedChange = { 77 | viewModel.toggleIgnoreNonFullScreenApps() 78 | }) 79 | }, 80 | leadingIcon = Icons.Default.Fullscreen 81 | ) 82 | } 83 | 84 | /** 85 | * The UI element for setting the allowed daily color screen time. On click, a dialog will be shown 86 | * that allows the user to set the allowed daily color screen time. 87 | */ 88 | @Composable 89 | fun AllowedDailyColorScreenTimeTile(viewModel: GrayscaleAppsFeatureSettingsViewModel = viewModel()) { 90 | val showAllowedDailyColorScreenTimeDialog = 91 | viewModel.showAllowedDailyColorScreenTimeDialog.collectAsState().value 92 | val usedUpScreenTime = GrayscaleAppsFeature.usedUpScreenTime.milliseconds.inWholeMinutes.toInt() 93 | val allowedDailyColorScreenTime = 94 | viewModel.allowedDailyColorScreenTime.collectAsState().value.toInt() 95 | if (showAllowedDailyColorScreenTimeDialog) { 96 | NumberPickerDialog(titleText = stringResource(id = R.string.feature_grayscale_allowedColorScreenTime), 97 | initialValue = allowedDailyColorScreenTime, 98 | onValueSelected = { viewModel.setAllowedDailyColorScreenTime(it.toLong()) }, 99 | onDismissRequest = { viewModel.setShowAllowedDailyColorScreenTimeDialog(false) }, 100 | range = 0..180 step 15, 101 | label = { it.minutes.toHrMinString() }) 102 | } 103 | val context = LocalContext.current 104 | val coroutineScope = rememberCoroutineScope() 105 | 106 | SimpleListTile( 107 | titleText = stringResource(id = R.string.feature_grayscale_allowedColorScreenTime), 108 | subtitleText = stringResource(id = R.string.feature_disableApps_allowedDailyTime_description), 109 | trailing = { 110 | Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { 111 | Text(stringResource(id = R.string.time__minutes, allowedDailyColorScreenTime)) 112 | Text( 113 | modifier = Modifier.padding(top = 8.dp), 114 | text = stringResource( 115 | id = R.string.time__minutes, usedUpScreenTime 116 | ) + "\n" + stringResource( 117 | id = R.string.time__minutes_used 118 | ), 119 | style = androidx.compose.material3.MaterialTheme.typography.labelVerySmall, 120 | textAlign = androidx.compose.ui.text.style.TextAlign.Center 121 | ) 122 | } 123 | }, 124 | onClick = { 125 | if (!viewModel.setShowAllowedDailyColorScreenTimeDialog(true)) { 126 | // the user has not given the permission to access usage stats, so we show a snackbar 127 | // to request the permission 128 | coroutineScope.launch { 129 | val result = FeatureScreenSnackbarStateProvider.snackbarState.showSnackbar( 130 | context.getString(R.string.action_requestPermissions), 131 | context.getString(R.string.action_go) 132 | ) 133 | if (result == SnackbarResult.ActionPerformed) { 134 | NavigationUtil.openUsageAccessSettings(context) 135 | } 136 | } 137 | } 138 | }, 139 | leadingIcon = Icons.Default.ColorLens 140 | ) 141 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/grayscale_apps/GrayscaleAppsFeatureSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.grayscale_apps 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.flx_apps.digitaldetox.features.GrayscaleAppsFeature 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import javax.inject.Inject 9 | import kotlin.time.Duration.Companion.milliseconds 10 | import kotlin.time.Duration.Companion.minutes 11 | 12 | /** 13 | * The view model for the grayscale apps feature settings tile. 14 | */ 15 | @HiltViewModel 16 | class GrayscaleAppsFeatureSettingsViewModel @Inject constructor() : ViewModel() { 17 | /** 18 | * Whether the extra dim feature is activated. 19 | * @see GrayscaleAppsFeature.extraDim 20 | */ 21 | private var _extraDimActivated: MutableStateFlow = 22 | MutableStateFlow(GrayscaleAppsFeature.extraDim) 23 | val extraDimActivated: StateFlow = _extraDimActivated 24 | 25 | /** 26 | * Whether the grayscale filter should be ignored when the current app is not in full screen mode. 27 | * @see GrayscaleAppsFeature.ignoreNonFullScreenApps 28 | */ 29 | private var _ignoreNonFullScreenApps: MutableStateFlow = 30 | MutableStateFlow(GrayscaleAppsFeature.ignoreNonFullScreenApps) 31 | val ignoreNonFullScreenApps: StateFlow = _ignoreNonFullScreenApps 32 | 33 | /** 34 | * The allowed daily color screen time *in minutes*. 35 | * @see GrayscaleAppsFeature.allowedDailyColorScreenTime 36 | */ 37 | private var _allowedDailyColorScreenTime: MutableStateFlow = 38 | MutableStateFlow(GrayscaleAppsFeature.allowedDailyColorScreenTime.milliseconds.inWholeMinutes) 39 | val allowedDailyColorScreenTime: StateFlow = _allowedDailyColorScreenTime 40 | 41 | /** 42 | * Whether the dialog to set the allowed daily color screen time should be shown. 43 | */ 44 | private var _showAllowedDailyColorScreenTimeDialog: MutableStateFlow = 45 | MutableStateFlow(false) 46 | val showAllowedDailyColorScreenTimeDialog: StateFlow = 47 | _showAllowedDailyColorScreenTimeDialog 48 | 49 | /** 50 | * Toggles the extra dim setting. 51 | */ 52 | fun toggleExtraDim() { 53 | GrayscaleAppsFeature.extraDim = !GrayscaleAppsFeature.extraDim 54 | _extraDimActivated.value = GrayscaleAppsFeature.extraDim 55 | } 56 | 57 | /** 58 | * Toggles the ignore non full screen apps setting. 59 | */ 60 | fun toggleIgnoreNonFullScreenApps() { 61 | GrayscaleAppsFeature.ignoreNonFullScreenApps = !GrayscaleAppsFeature.ignoreNonFullScreenApps 62 | _ignoreNonFullScreenApps.value = GrayscaleAppsFeature.ignoreNonFullScreenApps 63 | } 64 | 65 | /** 66 | * Sets whether the dialog to set the allowed daily color screen time should be shown. 67 | * @return whether the dialog can be shown (i.e. whether the user has given the permission to 68 | * access usage stats) 69 | */ 70 | fun setShowAllowedDailyColorScreenTimeDialog(show: Boolean): Boolean { 71 | _showAllowedDailyColorScreenTimeDialog.value = show 72 | return true 73 | } 74 | 75 | /** 76 | * Sets the allowed daily color screen time in minutes. 77 | */ 78 | fun setAllowedDailyColorScreenTime(minutes: Long) { 79 | GrayscaleAppsFeature.allowedDailyColorScreenTime = 80 | minutes.minutes.inWholeMilliseconds // convert to milliseconds 81 | _allowedDailyColorScreenTime.value = minutes 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/feature/pause_button/PauseButtonFeatureSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.feature.pause_button 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import android.provider.Settings 6 | import android.view.KeyEvent 7 | import android.widget.Toast 8 | import androidx.lifecycle.AndroidViewModel 9 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidAccessibilityService 10 | import com.flx_apps.digitaldetox.R 11 | import com.flx_apps.digitaldetox.features.PauseButtonFeature 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | import java.util.concurrent.TimeUnit 16 | import javax.inject.Inject 17 | 18 | /** 19 | * The possible dialogs that can be shown. 20 | * We use an enum, as only one dialog can be shown at a time. 21 | */ 22 | enum class PauseButtonFeatureSettingsViewModelDialog { 23 | NONE, PAUSE_DURATION, TIME_BETWEEN_PAUSES_DURATION, PICK_HARDWARE_KEY 24 | } 25 | 26 | /** 27 | * The view model for the pause button feature settings tile. 28 | */ 29 | @HiltViewModel 30 | class PauseButtonFeatureSettingsViewModel @Inject constructor(application: Application) : 31 | AndroidViewModel(application) { 32 | /** 33 | * The duration of the pause in minutes. 34 | */ 35 | private val _pauseDuration: MutableStateFlow = 36 | MutableStateFlow(TimeUnit.MILLISECONDS.toMinutes(PauseButtonFeature.pauseDuration).toInt()) 37 | val pauseDuration = _pauseDuration.asStateFlow() 38 | 39 | /** 40 | * The duration between pauses in minutes. 41 | */ 42 | private val _timeBetweenPausesDuration: MutableStateFlow = MutableStateFlow( 43 | TimeUnit.MILLISECONDS.toMinutes(PauseButtonFeature.timeBetweenPausesDuration).toInt() 44 | ) 45 | val timeBetweenPausesDuration = _timeBetweenPausesDuration.asStateFlow() 46 | 47 | /** 48 | * Whether to show the pause duration number picker dialog. 49 | */ 50 | private val _visibleDialog: MutableStateFlow = 51 | MutableStateFlow(PauseButtonFeatureSettingsViewModelDialog.NONE) 52 | val showPauseDurationNumberPickerDialog = _visibleDialog.asStateFlow() 53 | 54 | private val _hardwareKey = MutableStateFlow(PauseButtonFeature.hardwareKey) 55 | val hardwareKey = _hardwareKey.asStateFlow() 56 | 57 | private val _newHardwareKeySelection = MutableStateFlow(KeyEvent.KEYCODE_UNKNOWN) 58 | val newHardwareKeySelection = _newHardwareKeySelection.asStateFlow() 59 | 60 | /** 61 | * Sets the duration of the pause in minutes. 62 | */ 63 | fun setPauseDuration(durationInMinutes: Int) { 64 | PauseButtonFeature.pauseDuration = TimeUnit.MINUTES.toMillis(durationInMinutes.toLong()) 65 | _pauseDuration.value = durationInMinutes 66 | } 67 | 68 | /** 69 | * Sets the minimum time that has to pass between two pauses in minutes. 70 | */ 71 | fun setTimeBetweenPausesDuration(durationInMinutes: Int) { 72 | PauseButtonFeature.timeBetweenPausesDuration = 73 | TimeUnit.MINUTES.toMillis(durationInMinutes.toLong()) 74 | _timeBetweenPausesDuration.value = durationInMinutes 75 | } 76 | 77 | /** 78 | * Sets which dialog should be visible. 79 | */ 80 | fun setVisibilityOfDialog(dialog: PauseButtonFeatureSettingsViewModelDialog) { 81 | _visibleDialog.value = dialog 82 | } 83 | 84 | /** 85 | * Shows the hardware key dialog if the accessibility service is running and we can listen for 86 | * key events. 87 | */ 88 | fun showHardwareKeyDialog() { 89 | if (DetoxDroidAccessibilityService.instance == null) { 90 | // the accessibility service is not running, so we can't pick a hardware key 91 | val context = getApplication() 92 | Toast.makeText( 93 | context, 94 | context.getString(R.string.feature_pause_fromHardwareButton_appNotRunning), 95 | Toast.LENGTH_SHORT 96 | ).show() 97 | return 98 | } 99 | // we want to pick a hardware key, so we have to listen for key events 100 | _newHardwareKeySelection.value = _hardwareKey.value 101 | DetoxDroidAccessibilityService.instance?.onKeyEventListener = { event -> 102 | _newHardwareKeySelection.value = event.keyCode 103 | true 104 | } 105 | setVisibilityOfDialog(PauseButtonFeatureSettingsViewModelDialog.PICK_HARDWARE_KEY) 106 | } 107 | 108 | /** 109 | * Hides the hardware key dialog and sets the new hardware key. 110 | * @param newHardwareKeyCode The new hardware key code. 111 | */ 112 | fun hideHardwareKeyDialog(newHardwareKeyCode: Int? = null) { 113 | DetoxDroidAccessibilityService.instance?.onKeyEventListener = null 114 | if (newHardwareKeyCode != null) { 115 | _hardwareKey.value = newHardwareKeyCode 116 | PauseButtonFeature.hardwareKey = newHardwareKeyCode 117 | } 118 | setVisibilityOfDialog(PauseButtonFeatureSettingsViewModelDialog.NONE) 119 | } 120 | 121 | /** 122 | * Called when the user clicks on the "pause from assistant" tile. 123 | * Opens the Android settings for setting the assistant app. 124 | */ 125 | fun callAndroidAssistantSettings() { 126 | getApplication().startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS).apply { 127 | flags = Intent.FLAG_ACTIVITY_NEW_TASK 128 | }) 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.home; 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import android.provider.Settings 6 | import androidx.core.net.toUri 7 | import androidx.lifecycle.AndroidViewModel 8 | import com.flx_apps.digitaldetox.DetoxDroidApplication 9 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidAccessibilityService 10 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidDeviceAdminReceiver 11 | import com.flx_apps.digitaldetox.system_integration.DetoxDroidState 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import timber.log.Timber 16 | import javax.inject.Inject 17 | 18 | /** 19 | * The component name of the accessibility service. This is used to enable and disable the service. 20 | * @see HomeViewModel.activateAccessibilityService 21 | * @see HomeViewModel.disableAccessibilityService 22 | */ 23 | val AccessibilityServiceComponent = 24 | DetoxDroidApplication::class.java.`package`!!.name + "/" + DetoxDroidAccessibilityService::class.java.name 25 | 26 | /** 27 | * The state of the snackbar on the home screen. 28 | */ 29 | enum class HomeScreenSnackbarState { 30 | Hidden, ShowStartAcccessibilityServiceManually 31 | } 32 | 33 | @HiltViewModel 34 | class HomeViewModel @Inject constructor( 35 | private val application: Application 36 | ) : AndroidViewModel(application) { 37 | private val contentResolver = application.contentResolver 38 | 39 | /** 40 | * The current state of the accessibility service. This is a [StateFlow], so it can be observed 41 | * by other components. 42 | * @see DetoxDroidAccessibilityService.state 43 | */ 44 | val detoxDroidState: StateFlow = DetoxDroidAccessibilityService.state 45 | 46 | private val _snackbarState = MutableStateFlow(HomeScreenSnackbarState.Hidden) 47 | val snackbarState: StateFlow = _snackbarState 48 | 49 | /** 50 | * Toggles the state of the accessibility service. 51 | * @return the new state of the accessibility service or null if the (de-)activation failed 52 | * @see DetoxDroidAccessibilityService 53 | * @see DetoxDroidState 54 | */ 55 | fun toggleDetoxDroidIsRunning(): DetoxDroidState? { 56 | Timber.d("state = ${detoxDroidState.value}") 57 | val shouldBeRunning = detoxDroidState.value != DetoxDroidState.Active 58 | kotlin.runCatching { // if we don't have the permission to write secure settings, an exception will be thrown 59 | if (shouldBeRunning && activateAccessibilityService()) { 60 | return DetoxDroidState.Active 61 | } else if (!shouldBeRunning && disableAccessibilityService()) { 62 | return DetoxDroidState.Inactive 63 | } 64 | } 65 | 66 | // (de-)activation of accessibility service failed, so we need to show a snackbar to ask the user to do it manually 67 | setSnackbarState(HomeScreenSnackbarState.ShowStartAcccessibilityServiceManually) 68 | return null 69 | } 70 | 71 | fun setSnackbarState(state: HomeScreenSnackbarState) { 72 | _snackbarState.value = state 73 | } 74 | 75 | /** 76 | * Activates the accessibility service. This is done by adding the service to the list of 77 | * enabled accessibility services and starting the service. The service is then triggered 78 | * manually once to make sure it is running. 79 | * @see DetoxDroidAccessibilityService 80 | */ 81 | private fun activateAccessibilityService(): Boolean { 82 | val accessibilityServices = Settings.Secure.getString( 83 | contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES 84 | ).orEmpty() 85 | Settings.Secure.putString( 86 | contentResolver, 87 | Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, 88 | "$accessibilityServices:$AccessibilityServiceComponent".trim(':') 89 | ) 90 | Settings.Secure.putString( 91 | contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED, "1" 92 | ) 93 | return application.startService( 94 | Intent( 95 | application, DetoxDroidAccessibilityService::class.java 96 | ) 97 | ) != null 98 | } 99 | 100 | /** 101 | * Disables the accessibility service. 102 | * @see DetoxDroidAccessibilityService 103 | */ 104 | private fun disableAccessibilityService(): Boolean { 105 | val accessibilityServices = Settings.Secure.getString( 106 | contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES 107 | ).orEmpty() 108 | Settings.Secure.putString( 109 | contentResolver, 110 | Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, 111 | accessibilityServices.replace(AccessibilityServiceComponent, "").trim(':') 112 | ) 113 | return application.stopService( 114 | Intent( 115 | application, DetoxDroidAccessibilityService::class.java 116 | ) 117 | ) 118 | } 119 | 120 | /** 121 | * Stops DetoxDroid and all running features, revokes the device admin permission and uninstalls. 122 | */ 123 | fun uninstallDetoxDroid() { 124 | // call onDestroy() manually to run all cleanup tasks (e.g. stop all features) in a blocking way 125 | // (as application.stopService() is asynchronous) 126 | DetoxDroidAccessibilityService.instance?.onDestroy() 127 | kotlin.runCatching { disableAccessibilityService() } // run this anyway 128 | kotlin.runCatching { DetoxDroidDeviceAdminReceiver.revokePermission(application) } 129 | val intent = Intent(Intent.ACTION_DELETE) 130 | intent.data = "package:${application.packageName}".toUri() 131 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK 132 | application.startActivity(intent) 133 | } 134 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/nav_host/NavHostScreen.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.nav_host 2 | 3 | import ManageAppExceptionsScreen 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.BackHandler 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.platform.LocalContext 8 | import androidx.lifecycle.viewmodel.compose.viewModel 9 | import com.flx_apps.digitaldetox.ui.screens.feature.FeatureScreen 10 | import com.flx_apps.digitaldetox.ui.screens.home.HomeScreen 11 | import com.flx_apps.digitaldetox.ui.screens.permissions_required.PermissionsRequiredScreen 12 | import com.flx_apps.digitaldetox.ui.screens.schedule.FeatureScheduleScreen 13 | import dev.olshevski.navigation.reimagined.AnimatedNavHost 14 | 15 | /** 16 | * The navigation host for the app. It is responsible for routing to the correct screen based on 17 | * the current navigation state. 18 | */ 19 | @Composable 20 | fun NavHostScreen(navViewModel: NavViewModel = viewModel(viewModelStoreOwner = LocalContext.current as ComponentActivity)) { 21 | BackHandler(navViewModel.isBackHandlerEnabled) { 22 | navViewModel.onBackPress() 23 | } 24 | 25 | AnimatedNavHost( 26 | backstack = navViewModel.backstack 27 | ) { route -> 28 | when (route) { 29 | is NavigationRoutes.Home -> HomeScreen() 30 | 31 | is NavigationRoutes.ManageFeature -> FeatureScreen( 32 | featureId = route.featureId 33 | ) 34 | 35 | is NavigationRoutes.AppExceptions -> ManageAppExceptionsScreen( 36 | featureId = route.featureId 37 | ) 38 | 39 | is NavigationRoutes.FeatureSchedule -> FeatureScheduleScreen( 40 | featureId = route.featureId 41 | ) 42 | 43 | is NavigationRoutes.PermissionsRequired -> PermissionsRequiredScreen( 44 | grantPermissionsCommand = route.grantPermissionsCommand 45 | ) 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/nav_host/NavViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.nav_host 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.platform.LocalContext 5 | import androidx.lifecycle.SavedStateHandle 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewmodel.compose.viewModel 8 | import com.flx_apps.digitaldetox.MainActivity 9 | import com.flx_apps.digitaldetox.feature_types.FeatureId 10 | import com.flx_apps.digitaldetox.features.FeaturesProvider 11 | import com.flx_apps.digitaldetox.ui.screens.permissions_required.PermissionsRequiredScreen 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import dev.olshevski.navigation.reimagined.navController 14 | import dev.olshevski.navigation.reimagined.navigate 15 | import dev.olshevski.navigation.reimagined.pop 16 | import javax.inject.Inject 17 | 18 | val DefaultFeatureId = FeaturesProvider.featureList[0].id 19 | 20 | /** 21 | * The view model for the navigation host. It is responsible for managing the navigation state. 22 | */ 23 | @HiltViewModel 24 | class NavViewModel @Inject constructor(private val savedStateHandle: SavedStateHandle) : 25 | ViewModel() { 26 | companion object { 27 | /** 28 | * A factory function that creates a [NavViewModel]. We need to set the [viewModelStoreOwner] 29 | * to the [MainActivity] in order to avoid creating multiple instances of the view model. 30 | */ 31 | @Composable 32 | fun navViewModel(): NavViewModel = 33 | viewModel(viewModelStoreOwner = LocalContext.current as MainActivity) 34 | } 35 | 36 | /** 37 | * The navigation controller that manages the navigation state. 38 | */ 39 | private val navController = 40 | navController(startDestination = NavigationRoutes.Home) 41 | 42 | // You may either make navController public or just its backstack. The latter is convenient 43 | // when you don't want to expose navigation methods in the UI layer. 44 | val backstack get() = navController.backstack 45 | val isBackHandlerEnabled get() = navController.backstack.entries.size > 1 46 | 47 | /** 48 | * Handles the back press event. 49 | */ 50 | fun onBackPress() { 51 | navController.pop() 52 | } 53 | 54 | fun openManageFeatureScreen( 55 | featureId: FeatureId 56 | ) { 57 | savedStateHandle["featureId"] = featureId 58 | navController.navigate(NavigationRoutes.ManageFeature(featureId)) 59 | } 60 | 61 | fun openAppExceptionsScreen( 62 | featureId: FeatureId = savedStateHandle["featureId"] ?: DefaultFeatureId 63 | ) { 64 | savedStateHandle["featureId"] = featureId 65 | navController.navigate(NavigationRoutes.AppExceptions(featureId)) 66 | } 67 | 68 | fun openFeatureScheduleScreen( 69 | featureId: FeatureId = savedStateHandle["featureId"] ?: DefaultFeatureId 70 | ) { 71 | savedStateHandle["featureId"] = featureId 72 | navController.navigate(NavigationRoutes.FeatureSchedule(featureId)) 73 | } 74 | 75 | /** 76 | * Opens the screen to show the user how to grant required permissions. 77 | * @see PermissionsRequiredScreen 78 | */ 79 | fun openPermissionsRequiredScreen(grantPermissionsCommand: String) { 80 | navController.navigate(NavigationRoutes.PermissionsRequired(grantPermissionsCommand)) 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/nav_host/NavigationRoutes.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.nav_host 2 | 3 | import android.os.Parcelable 4 | import com.flx_apps.digitaldetox.feature_types.FeatureId 5 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavigationRoutes.AppExceptions 6 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavigationRoutes.FeatureSchedule 7 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavigationRoutes.ManageFeature 8 | import kotlinx.parcelize.Parcelize 9 | 10 | /** 11 | * The routes that can be navigated to in the app. Although the [AppExceptions] route and the 12 | * [FeatureSchedule] route are in principle downstream of the [ManageFeature] route, they are 13 | * configured as top-level routes for the sake of simplicity. 14 | */ 15 | sealed class NavigationRoutes : Parcelable { 16 | @Parcelize 17 | data object Home : NavigationRoutes() 18 | 19 | @Parcelize 20 | data class ManageFeature( 21 | val featureId: FeatureId 22 | ) : NavigationRoutes() 23 | 24 | @Parcelize 25 | data class AppExceptions( 26 | val featureId: FeatureId 27 | ) : NavigationRoutes() 28 | 29 | @Parcelize 30 | data class FeatureSchedule( 31 | val featureId: FeatureId 32 | ) : NavigationRoutes() 33 | 34 | @Parcelize 35 | data class PermissionsRequired( 36 | val grantPermissionsCommand: String 37 | ) : NavigationRoutes() 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/permissions_required/PermissionsRequiredScreen.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.permissions_required 2 | 3 | import HyperlinkText 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.ArrowBack 13 | import androidx.compose.material.icons.filled.Info 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.IconButton 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.OutlinedButton 19 | import androidx.compose.material3.Scaffold 20 | import androidx.compose.material3.Text 21 | import androidx.compose.material3.TopAppBar 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.text.font.FontFamily 27 | import androidx.compose.ui.unit.dp 28 | import com.flx_apps.digitaldetox.R 29 | import com.flx_apps.digitaldetox.ui.screens.nav_host.NavViewModel 30 | import com.flx_apps.digitaldetox.util.RootShellCommand 31 | import com.stericson.RootShell.RootShell 32 | 33 | /** 34 | * The screen that is shown when specific permissions need to be granted from the computer (and 35 | * cannot be granted from the user within the Android OS). 36 | * @param grantPermissionsCommand The command that needs to be executed on the computer to grant 37 | * the required permissions. 38 | */ 39 | @OptIn(ExperimentalMaterial3Api::class) 40 | @Composable 41 | fun PermissionsRequiredScreen( 42 | grantPermissionsCommand: String, navViewModel: NavViewModel = NavViewModel.navViewModel() 43 | ) { 44 | Scaffold(topBar = { 45 | TopAppBar(title = {}, navigationIcon = { 46 | IconButton(onClick = { 47 | navViewModel.onBackPress() 48 | }) { 49 | Icon( 50 | imageVector = Icons.Default.ArrowBack, 51 | contentDescription = "Back", 52 | modifier = Modifier.size(24.dp) 53 | ) 54 | } 55 | }) 56 | }) { 57 | Box( 58 | modifier = Modifier 59 | .padding(it) 60 | .padding(16.dp) 61 | ) { 62 | PermissionsRequiredScreenContent(grantPermissionsCommand) 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * The content of the [PermissionsRequiredScreen]. 69 | * It contains a short explanation of why the permissions are required and how to grant them using 70 | * adb and a command that is provided as a parameter. 71 | * If the device is rooted, it also offers a button to grant the permissions using root shell. 72 | */ 73 | @Composable 74 | fun PermissionsRequiredScreenContent( 75 | grantPermissionsCommand: String, navViewModel: NavViewModel = NavViewModel.navViewModel() 76 | ) { 77 | val isRootAvailable = RootShell.isRootAvailable() 78 | Column( 79 | modifier = Modifier 80 | .fillMaxSize() 81 | .padding(32.dp), verticalArrangement = Arrangement.Center 82 | ) { 83 | Icon( 84 | imageVector = Icons.Default.Info, 85 | contentDescription = "Info", 86 | modifier = Modifier 87 | .align(Alignment.CenterHorizontally) 88 | .size(128.dp) 89 | .weight(1f) 90 | ) 91 | Spacer(modifier = Modifier.weight(0.25f)) 92 | Text( 93 | text = stringResource(id = R.string.noPermissions_text), 94 | style = MaterialTheme.typography.bodyLarge 95 | ) 96 | Text( 97 | text = stringResource( 98 | id = R.string.noPermissions_text_command, grantPermissionsCommand 99 | ), 100 | style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), 101 | modifier = Modifier.padding(vertical = 32.dp) 102 | ) 103 | HyperlinkText( 104 | fullTextResId = if (isRootAvailable) R.string.noPermissions_text_rooted else R.string.noPermissions_text_notRooted, 105 | linksActions = listOf("GITHUB"), 106 | hyperLinks = listOf("https://github.com/flxapps/DetoxDroid"), 107 | fontSize = MaterialTheme.typography.bodyMedium.fontSize 108 | ) 109 | if (isRootAvailable) { 110 | OutlinedButton(modifier = Modifier 111 | .padding(top = 8.dp) 112 | .align(Alignment.CenterHorizontally), 113 | onClick = { 114 | // try grant permissions using root shell 115 | kotlin.runCatching { 116 | RootShell.getShell(true) 117 | .add(RootShellCommand(grantPermissionsCommand) { _, _ -> 118 | navViewModel.onBackPress() 119 | }) 120 | } 121 | }) { 122 | Text(text = stringResource(id = R.string.noPermissions_text_rooted_go)) 123 | } 124 | } 125 | Spacer(modifier = Modifier.weight(0.5f)) 126 | } 127 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/screens/schedule/ScheduleViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.screens.schedule 2 | 3 | import android.app.Application 4 | import com.flx_apps.digitaldetox.feature_types.FeatureScheduleRule 5 | import com.flx_apps.digitaldetox.feature_types.SupportsScheduleFeature 6 | import com.flx_apps.digitaldetox.features.FeaturesProvider 7 | import com.flx_apps.digitaldetox.ui.screens.feature.FeatureViewModel 8 | import com.flx_apps.digitaldetox.ui.screens.feature.FeatureViewModelFactory 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.SharedFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import java.time.DayOfWeek 14 | import java.time.LocalTime 15 | import javax.inject.Inject 16 | 17 | /** 18 | * The ID of a rule. The ID is generated as the hash code of the rule and has no meaning beyond 19 | * the scope of the schedule screen and view model (i.e. it is not stored in the data store). 20 | */ 21 | typealias ScheduleRuleId = Int 22 | 23 | /** 24 | * In the ScheduleViewModel, a rule is represented by a pair of its ID and the rule itself. We need 25 | * the id in order to be able to edit and delete rules. 26 | * 27 | * @see ScheduleRuleId 28 | */ 29 | typealias ScheduleRuleItem = Pair 30 | 31 | /** 32 | * The view model for the schedule screen. It contains the rules for when the feature should be 33 | * active or inactive (@see FeatureScheduleRule) and provides methods to add, edit and delete rules. 34 | */ 35 | @HiltViewModel 36 | class ScheduleViewModel @Inject constructor( 37 | private val application: Application, 38 | private val savedStateHandle: androidx.lifecycle.SavedStateHandle 39 | ) : FeatureViewModel(application, savedStateHandle) { 40 | companion object : FeatureViewModelFactory() 41 | 42 | private val scheduleFeature = feature as SupportsScheduleFeature 43 | 44 | private val _rules = 45 | MutableStateFlow(scheduleFeature.scheduleRules.associateBy { featureScheduleRule -> 46 | featureScheduleRule.hashCode() 47 | }) 48 | val rules: StateFlow> = _rules 49 | 50 | private val _dialogRule = MutableStateFlow(null) 51 | val dialogRule: SharedFlow = _dialogRule 52 | 53 | /** 54 | * Shows the bottom sheet for adding/editing a rule. 55 | */ 56 | fun showBottomSheet( 57 | rule: ScheduleRuleItem = -1 to FeatureScheduleRule( 58 | listOf(), LocalTime.of(0, 0), LocalTime.of(0, 0) 59 | ) 60 | ) { 61 | _dialogRule.value = rule.first to rule.second 62 | } 63 | 64 | /** 65 | * Updates the rule that is currently shown in the bottom sheet with the given values. 66 | */ 67 | fun updateBottomSheet( 68 | daysOfWeek: List? = null, start: LocalTime? = null, end: LocalTime? = null 69 | ) { 70 | _dialogRule.value = _dialogRule.value!!.first to _dialogRule.value!!.second.copyWith( 71 | daysOfWeek = daysOfWeek, start = start, end = end 72 | ) 73 | } 74 | 75 | /** 76 | * Hides the bottom sheet. 77 | */ 78 | fun hideBottomSheet() { 79 | _dialogRule.tryEmit(null) 80 | } 81 | 82 | /** 83 | * Saves the rule that is currently shown in the bottom sheet. If the rule has no ID yet, it is 84 | * a new rule and will be added to the list of rules. If it already has an ID, it is an existing 85 | * rule and will be updated. 86 | */ 87 | fun onSaveClick() { 88 | val rule = _dialogRule.value!! 89 | if (rule.first == -1) { 90 | _rules.value = _rules.value + (rule.second.hashCode() to rule.second) 91 | } else { 92 | _rules.value = _rules.value + rule 93 | } 94 | scheduleFeature.scheduleRules = _rules.value.values.toSet() 95 | hideBottomSheet() 96 | FeaturesProvider.startOrStopFeature(feature) 97 | } 98 | 99 | /** 100 | * Deletes the rule that is currently shown in the bottom sheet. 101 | */ 102 | fun onDeleteClick() { 103 | val ruleId = _dialogRule.value!!.first 104 | _rules.value = _rules.value.filterKeys { it != ruleId } 105 | scheduleFeature.scheduleRules = _rules.value.values.toSet() 106 | hideBottomSheet() 107 | FeaturesProvider.startOrStopFeature(feature) 108 | } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | 18 | private val DarkColorScheme = darkColorScheme( 19 | primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 20 | ) 21 | 22 | private val LightColorScheme = lightColorScheme( 23 | primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White,˝ 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F),˝ 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun DetoxDroidTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, content: @Composable () -> Unit 41 | ) { 42 | val colorScheme = when { 43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 44 | val context = LocalContext.current 45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 46 | } 47 | 48 | darkTheme -> DarkColorScheme 49 | else -> LightColorScheme 50 | } 51 | val view = LocalView.current 52 | if (!view.isInEditMode && view.context is Activity) { 53 | SideEffect { 54 | val window = (view.context as Activity).window 55 | window.statusBarColor = colorScheme.primary.toArgb() 56 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 57 | } 58 | } 59 | 60 | MaterialTheme( 61 | colorScheme = colorScheme, typography = Typography, content = content 62 | ) 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.theme 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Typography 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.text.TextStyle 7 | import androidx.compose.ui.text.font.FontFamily 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.unit.sp 10 | 11 | // Set of Material typography styles to start with 12 | val Typography = Typography( 13 | bodyLarge = TextStyle( 14 | fontFamily = FontFamily.Default, 15 | fontWeight = FontWeight.Normal, 16 | fontSize = 16.sp, 17 | lineHeight = 24.sp, 18 | letterSpacing = 0.5.sp 19 | )/* Other default text styles to override 20 | titleLarge = TextStyle( 21 | fontFamily = FontFamily.Default, 22 | fontWeight = FontWeight.Normal, 23 | fontSize = 22.sp, 24 | lineHeight = 28.sp, 25 | letterSpacing = 0.sp 26 | ), 27 | labelSmall = TextStyle( 28 | fontFamily = FontFamily.Default, 29 | fontWeight = FontWeight.Medium, 30 | fontSize = 11.sp, 31 | lineHeight = 16.sp, 32 | letterSpacing = 0.5.sp 33 | ) 34 | */ 35 | ) 36 | 37 | val Typography.labelVerySmall: TextStyle 38 | @Composable get() = Typography.bodySmall.copy( 39 | fontWeight = FontWeight.Normal, 40 | fontSize = 10.sp, 41 | lineHeight = 12.sp, 42 | letterSpacing = 0.4.sp, 43 | color = MaterialTheme.colorScheme.secondary 44 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/Center.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.widgets 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.ColumnScope 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | 9 | /** 10 | * A composable that centers its content. It is basically just a wrapper for [Column] and reduces 11 | * some boilerplate. 12 | */ 13 | @Composable 14 | fun Center(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { 15 | Column( 16 | modifier = modifier.fillMaxSize(), 17 | horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, 18 | verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center 19 | ) { 20 | content() 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/DropdownIconButton.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.widgets 2 | 3 | import androidx.compose.material.DropdownMenu 4 | import androidx.compose.material.IconButton 5 | import androidx.compose.material3.DropdownMenuItem 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.collectAsState 9 | import androidx.compose.runtime.remember 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | 12 | /** 13 | * A dropdown menu that is opened by clicking on an icon button. 14 | * @param icon The icon button that opens the dropdown menu. 15 | * @param items The items of the dropdown menu. 16 | * @param onItemSelected A callback that is called when the user selects an item. 17 | */ 18 | @Composable 19 | fun DropdownIconButton( 20 | icon: @Composable () -> Unit, items: List, onItemSelected: (Any) -> Unit 21 | ) { 22 | val expanded = remember { 23 | MutableStateFlow(false) 24 | } 25 | DropdownMenu( 26 | expanded = expanded.collectAsState().value, 27 | onDismissRequest = { expanded.value = false }) { 28 | items.forEach { item -> 29 | DropdownMenuItem(text = { Text(text = item) }, onClick = { 30 | onItemSelected(item) 31 | expanded.value = false 32 | }) 33 | } 34 | } 35 | 36 | IconButton(onClick = { expanded.value = true }) { 37 | icon() 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/HyperlinkText.kt: -------------------------------------------------------------------------------- 1 | import android.text.Annotation 2 | import androidx.annotation.StringRes 3 | import androidx.compose.foundation.text.ClickableText 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.platform.LocalContext 8 | import androidx.compose.ui.platform.LocalUriHandler 9 | import androidx.compose.ui.text.SpanStyle 10 | import androidx.compose.ui.text.TextStyle 11 | import androidx.compose.ui.text.buildAnnotatedString 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.text.style.TextDecoration 14 | import androidx.compose.ui.unit.TextUnit 15 | import androidx.core.text.toSpannable 16 | 17 | /** 18 | * Usage: 19 | * 20 | * HyperlinkText( 21 | * modifier = Modifier.fillMaxWidth(), 22 | * fullTextResId = R.string.text_for_link, 23 | * linksActions = listOf("LINK"), 24 | * hyperLinks = listOf("https://google.com") 25 | * ) 26 | * 27 | * With text contain a link 28 | * 29 | * @see https://gist.github.com/stevdza-san/ff9dbec0e072d8090e1e6d16e6b73c91 30 | */ 31 | @Composable 32 | fun HyperlinkText( 33 | modifier: Modifier = Modifier, 34 | @StringRes fullTextResId: Int, 35 | linksActions: List, 36 | hyperLinks: List, 37 | textStyle: TextStyle = TextStyle.Default, 38 | linkTextColor: Color = Color.Blue, 39 | linkTextFontWeight: FontWeight = FontWeight.Normal, 40 | linkTextDecoration: TextDecoration = TextDecoration.None, 41 | fontSize: TextUnit = TextUnit.Unspecified 42 | ) { 43 | val fullText = LocalContext.current.getText(fullTextResId).toSpannable() 44 | val annotations = fullText.getSpans(0, fullText.length, Annotation::class.java) 45 | 46 | val annotatedString = buildAnnotatedString { 47 | append(fullText) 48 | linksActions.forEachIndexed { index, actionAnnotation -> 49 | annotations?.find { it.value == actionAnnotation }?.let { 50 | addStyle( 51 | style = SpanStyle( 52 | color = linkTextColor, 53 | fontSize = fontSize, 54 | fontWeight = linkTextFontWeight, 55 | textDecoration = linkTextDecoration 56 | ), start = fullText.getSpanStart(it), end = fullText.getSpanEnd(it) 57 | ) 58 | addStringAnnotation( 59 | tag = "URL", 60 | annotation = hyperLinks[index], 61 | start = fullText.getSpanStart(it), 62 | end = fullText.getSpanEnd(it) 63 | ) 64 | } 65 | addStyle( 66 | style = SpanStyle( 67 | fontSize = fontSize 68 | ), start = 0, end = fullText.length 69 | ) 70 | } 71 | } 72 | 73 | val uriHandler = LocalUriHandler.current 74 | 75 | ClickableText(modifier = modifier, text = annotatedString, style = textStyle, onClick = { 76 | annotatedString.getStringAnnotations("URL", it, it).firstOrNull()?.let { stringAnnotation -> 77 | uriHandler.openUri(stringAnnotation.item) 78 | } 79 | }) 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/Indicator.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.background 2 | import androidx.compose.foundation.layout.Box 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.draw.clip 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.res.colorResource 11 | import androidx.compose.ui.unit.dp 12 | import com.flx_apps.digitaldetox.R 13 | 14 | /** 15 | * A small circle that can be used as an indicator for something. It is basically just a wrapper for 16 | * [Box] and reduces some boilerplate. 17 | * @param modifier The modifier to be applied to the indicator. 18 | * @param indicatorColor The color of the indicator. 19 | */ 20 | @Composable 21 | fun StatusIndicator( 22 | modifier: Modifier = Modifier, indicatorColor: Color = colorResource(id = R.color.green) 23 | ) { 24 | Box( 25 | modifier = Modifier 26 | .padding(end = 4.dp) 27 | .size(8.dp) 28 | .clip(CircleShape) 29 | .background(color = indicatorColor) 30 | ) 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/InfoCard.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.widgets 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Info 8 | import androidx.compose.material3.Card 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | 16 | /** 17 | * A card that displays a text with an info icon. It is basically just a wrapper for [Card] and 18 | * reduces some boilerplate, as this widget is used quite often in the app. 19 | */ 20 | @Composable 21 | fun InfoCard(infoText: String) { 22 | Card(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { 23 | Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)) { 24 | Icon( 25 | tint = MaterialTheme.colorScheme.primary, 26 | imageVector = Icons.Default.Info, 27 | contentDescription = "Feature Description", 28 | modifier = Modifier.size(24.dp) 29 | ) 30 | Text( 31 | infoText, 32 | style = MaterialTheme.typography.bodyMedium, 33 | modifier = Modifier.padding(start = 8.dp) 34 | ) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/NumberPickerDialog.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.widgets 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import com.chargemap.compose.numberpicker.NumberPicker 14 | import com.flx_apps.digitaldetox.R 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | 17 | /** 18 | * A dialog that displays a number picker. It is basically just a wrapper for [AlertDialog] and 19 | * reduces some boilerplate. The number picker is provided by the [NumberPicker] library. 20 | * @param titleText The title of the dialog. 21 | * @param initialValue The initial value of the number picker. 22 | * @param range The range of the number picker. 23 | * @param label A function that converts the number picker value to a string. 24 | * @param onValueSelected A callback that is called when the user selects a value. 25 | * @param onDismissRequest A callback that is called when the user dismisses the dialog. 26 | * @see NumberPicker 27 | * @see AlertDialog 28 | */ 29 | @Composable 30 | fun NumberPickerDialog( 31 | titleText: String = "Number Picker", 32 | initialValue: Int, 33 | range: Iterable = 0..100, 34 | label: (Int) -> String = { it.toString() }, 35 | onValueSelected: (Int) -> Unit, 36 | onDismissRequest: () -> Unit = {} 37 | ) { 38 | val numberPickerValue = MutableStateFlow(initialValue) 39 | AlertDialog(onDismissRequest = onDismissRequest, title = { 40 | Text(text = titleText) 41 | }, confirmButton = { 42 | Text(text = stringResource(id = R.string.action_save), modifier = Modifier.clickable { 43 | onValueSelected(numberPickerValue.value) 44 | onDismissRequest() 45 | }) 46 | }, dismissButton = { 47 | Text( 48 | text = stringResource(id = R.string.action_cancel), 49 | modifier = Modifier.clickable { onDismissRequest() }) 50 | }, text = { 51 | Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { 52 | NumberPicker(value = numberPickerValue.collectAsState().value, onValueChange = { 53 | numberPickerValue.value = it 54 | }, range = range, label = label) 55 | } 56 | }) 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/OptionsRow.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.layout.Arrangement 2 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 3 | import androidx.compose.foundation.layout.FlowRow 4 | import androidx.compose.material.ExperimentalMaterialApi 5 | import androidx.compose.material.FilterChip 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.compose.ui.unit.dp 10 | 11 | /** 12 | * Displays a set of options as [FilterChip]s in a [FlowRow]. 13 | * @param options A map of text resources to option values 14 | * @param selectedOption The currently selected option 15 | * @param onOptionSelected A callback that is called when an option is selected 16 | */ 17 | @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) 18 | @Composable 19 | fun OptionsRow( 20 | options: Map, 21 | selectedOption: Any, 22 | onOptionSelected: (Any) -> Unit, 23 | ) { 24 | FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { 25 | options.forEach { (textRes, option) -> 26 | FilterChip( 27 | selected = option == selectedOption, 28 | onClick = { onOptionSelected(option) }, 29 | ) { 30 | Text(text = stringResource(id = textRes)) 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/ui/widgets/SimpleListTile.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.ui.widgets 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.material3.Icon 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | 11 | /** 12 | * A simple list tile with a title, subtitle, leading icon and trailing content. It is basically 13 | * just a wrapper for [androidx.compose.material3.ListItem] and reduces some boilerplate, as this 14 | * widget is used quite often in the app. 15 | */ 16 | @OptIn(ExperimentalFoundationApi::class) 17 | @Composable 18 | fun SimpleListTile( 19 | titleText: String, 20 | subtitleText: String, 21 | leadingIcon: ImageVector? = null, 22 | trailing: @Composable () -> Unit = {}, 23 | onClick: () -> Unit = {}, 24 | onLongClick: () -> Unit = {} 25 | ) { 26 | androidx.compose.material3.ListItem(headlineContent = { 27 | Text(titleText) 28 | }, supportingContent = { 29 | Text(subtitleText) 30 | }, trailingContent = trailing, modifier = Modifier.combinedClickable( 31 | onClick = onClick, onLongClick = onLongClick 32 | ), leadingContent = if (leadingIcon != null) { 33 | { Icon(imageVector = leadingIcon, contentDescription = null) } 34 | } else null) 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/util/AccessibilityEventUtil.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.util 2 | 3 | import android.view.accessibility.AccessibilityEvent 4 | 5 | class AccessibilityEventUtil { 6 | companion object { 7 | fun createEvent(eventType: Int = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED): AccessibilityEvent { 8 | val accessibilityEvent = 9 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { 10 | // for API level 33 and above, we need to use the AccessibilityEvent constructor 11 | AccessibilityEvent(eventType) 12 | } else { 13 | // for older API levels, we can use the AccessibilityEvent.obtain() method 14 | AccessibilityEvent.obtain(eventType) 15 | }.apply { 16 | if (packageName == null) { 17 | packageName = "com.flx_apps.digitaldetox" 18 | } 19 | isFullScreen = true 20 | } 21 | return accessibilityEvent 22 | } 23 | 24 | /** 25 | * Returns the scroll delta of the given [accessibilityEvent] in the y direction. 26 | * This is only available for API level 28 and above, otherwise, 1 is returned. 27 | */ 28 | fun getScrollDeltaY(accessibilityEvent: AccessibilityEvent): Int { 29 | var result = 1 // assume a positive scroll delta by default 30 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { 31 | result = accessibilityEvent.scrollDeltaY 32 | } 33 | return result 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/util/ComposableExt.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleEventObserver 10 | 11 | /** 12 | * @see https://stackoverflow.com/a/69061897 13 | */ 14 | @Composable 15 | fun Lifecycle.observeAsState(): State { 16 | val state = remember { mutableStateOf(Lifecycle.Event.ON_ANY) } 17 | DisposableEffect(this) { 18 | val observer = LifecycleEventObserver { _, event -> 19 | state.value = event 20 | } 21 | this@observeAsState.addObserver(observer) 22 | onDispose { 23 | this@observeAsState.removeObserver(observer) 24 | } 25 | } 26 | return state 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/util/DurationExt.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.util 2 | 3 | import kotlin.time.Duration 4 | 5 | /** 6 | * Converts a duration to a string in the format "h min". 7 | */ 8 | fun Duration.toHrMinString(): String { 9 | val hours = this.inWholeHours 10 | val minutes = this.inWholeMinutes % 60 11 | return if (hours > 0) { 12 | "$hours h $minutes min" 13 | } else { 14 | "$minutes min" 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/util/KeyEventUtil.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.util 2 | 3 | import android.view.KeyEvent 4 | 5 | object KeyEventUtil { 6 | /** 7 | * Returns a short string representation of the given key code. Basically, it just removes the 8 | * "KEYCODE_" prefix. 9 | */ 10 | fun keyCodeToShortString(keyCode: Int): String { 11 | return KeyEvent.keyCodeToString(keyCode).replace("KEYCODE_", "") 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/util/NavigationUtil.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.provider.Settings 7 | 8 | object NavigationUtil { 9 | /** 10 | * Opens the settings screen for the draw overlay permission. 11 | */ 12 | @JvmStatic 13 | fun openOverlayPermissionsSettings(context: Context) { 14 | context.startActivity(Intent( 15 | Settings.ACTION_MANAGE_OVERLAY_PERMISSION, 16 | Uri.parse("package:${context.packageName}") 17 | ).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }) 18 | } 19 | 20 | /** 21 | * Opens the settings screen for the do not disturb permission. 22 | */ 23 | @JvmStatic 24 | fun openDoNotDisturbSystemSettings(context: Context) { 25 | context.startActivity(Intent( 26 | Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS) 27 | ).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }) 28 | } 29 | 30 | /** 31 | * Opens the settings screen for the usage stats permission. 32 | */ 33 | @JvmStatic 34 | fun openUsageAccessSettings(context: Context) { 35 | context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply { 36 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 37 | }) 38 | } 39 | 40 | /** 41 | * Opens the accessibility services settings screen. 42 | */ 43 | @JvmStatic 44 | fun openAccessibilitySettings(context: Context) { 45 | context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { 46 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 47 | }) 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flx_apps/digitaldetox/util/RootShellCommand.kt: -------------------------------------------------------------------------------- 1 | package com.flx_apps.digitaldetox.util 2 | 3 | import com.stericson.RootShell.execution.Command 4 | 5 | class RootShellCommand( 6 | rootCommand: String, private val onCompleted: ((Int, Int) -> Unit)? 7 | ) : Command(0, rootCommand) { 8 | override fun commandCompleted(id: Int, exitcode: Int) { 9 | super.commandCompleted(id, exitcode) 10 | onCompleted?.invoke(id, exitcode) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_app_exceptions.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_contrast.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_disable_app.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_do_not_disturb.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 19 | 23 | 27 | 31 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 13 | 16 | 19 | 22 | 25 | 28 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground_cropped.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 19 | 22 | 25 | 28 | 31 | 34 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground_outlined.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pause.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_schedule.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_scroll.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_start.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_stop.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flxapps/DetoxDroid/07767fe215f30935e4143fff9dcba12febb6970b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flxapps/DetoxDroid/07767fe215f30935e4143fff9dcba12febb6970b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flxapps/DetoxDroid/07767fe215f30935e4143fff9dcba12febb6970b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flxapps/DetoxDroid/07767fe215f30935e4143fff9dcba12febb6970b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flxapps/DetoxDroid/07767fe215f30935e4143fff9dcba12febb6970b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | #808080 12 | 13 | #dc2863 14 | #e86942 15 | #ffbf0b 16 | #00af64 17 | #0b85ff 18 | #6642ad 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |