├── .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 | 
4 | [](https://github.com/flxapps/DetoxDroid/releases/) [](https://github.com/flxapps/DetoxDroid/blob/master/LICENSE) [](https://github.com/flxapps/DetoxDroid/graphs/commit-activity) [](https://ko-fi.com/flxapps/3) [](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