├── .editorconfig
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── buildbot.yml
│ └── dependencies.yml
├── .gitignore
├── .idea
├── .name
├── codeInsightSettings.xml
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── compiler.xml
├── detekt.xml
├── encodings.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
├── runConfigurations
│ └── Run_static_analysis.xml
└── vcs.xml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── app
├── build.gradle.kts
├── schemas
│ └── dev.sebastiano.bundel.storage.RobertoDatabase
│ │ ├── 1.json
│ │ ├── 2.json
│ │ └── 3.json
└── src
│ ├── androidTest
│ ├── AndroidManifest.xml
│ └── kotlin
│ │ └── dev
│ │ └── sebastiano
│ │ └── bundel
│ │ ├── FakePreferences.kt
│ │ ├── RobertoFoldTest.kt
│ │ └── onboarding
│ │ ├── OnboardingAndroidUiTest.kt
│ │ ├── PrepareDevicePermissionsRule.kt
│ │ └── PrepareTestStorageRule.kt
│ ├── debug
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── dev
│ │ │ └── sebastiano
│ │ │ └── bundel
│ │ │ └── glance
│ │ │ └── GlanceDebugActivity.kt
│ └── res
│ │ └── values
│ │ └── strings.xml
│ ├── main
│ ├── AndroidManifest.xml
│ ├── assets
│ │ └── licences.json
│ ├── ic_bundel_launcher-playstore.png
│ ├── java
│ │ └── dev
│ │ │ └── sebastiano
│ │ │ └── bundel
│ │ │ ├── ApplicationModule.kt
│ │ │ ├── BundelApplication.kt
│ │ │ ├── DispatchersModule.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── MainScreenWithBottomNav.kt
│ │ │ ├── glance
│ │ │ ├── BundelAppWidgetReceiver.kt
│ │ │ ├── CannoliWidget.kt
│ │ │ └── TestWidgetScreen.kt
│ │ │ ├── history
│ │ │ └── HistoryScreen.kt
│ │ │ ├── navigation
│ │ │ ├── NavigationRoute.kt
│ │ │ └── TopLevelNavigation.kt
│ │ │ ├── notifications
│ │ │ ├── ActiveNotification.kt
│ │ │ ├── BundelNotificationListenerService.kt
│ │ │ ├── NotificationServiceHelpers.kt
│ │ │ ├── PersistableNotification.kt
│ │ │ └── StatusBarNotificationExtensions.kt
│ │ │ ├── notificationslist
│ │ │ ├── NotificationItem.kt
│ │ │ ├── NotificationsListEmptyState.kt
│ │ │ └── NotificationsListScreen.kt
│ │ │ ├── onboarding
│ │ │ ├── DaysSchedulePage.kt
│ │ │ ├── DaysSchedulePageState.kt
│ │ │ ├── HoursSchedulePageState.kt
│ │ │ ├── IntroPage.kt
│ │ │ ├── NotificationsAccessPageState.kt
│ │ │ ├── OnboardingScreen.kt
│ │ │ ├── OnboardingViewModel.kt
│ │ │ └── SimplePageIndicator.kt
│ │ │ ├── schedule
│ │ │ └── ScheduleChecker.kt
│ │ │ ├── storage
│ │ │ ├── Dao.kt
│ │ │ ├── DataRepository.kt
│ │ │ ├── DatabaseModule.kt
│ │ │ ├── DiskImagesStorage.kt
│ │ │ ├── ImagesStorage.kt
│ │ │ ├── RobertoDatabase.kt
│ │ │ ├── migrations
│ │ │ │ └── Migration1to2.kt
│ │ │ └── model
│ │ │ │ └── DbNotification.kt
│ │ │ ├── ui
│ │ │ └── NavTransitions.kt
│ │ │ └── util
│ │ │ ├── CurrentOrientation.kt
│ │ │ ├── IconExtensions.kt
│ │ │ ├── KotlinExt.kt
│ │ │ └── Orientation.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_bundel_launcher_foreground.xml
│ │ ├── ic_round_settings_24.xml
│ │ └── misaligned_floor.xml
│ │ ├── mipmap-anydpi
│ │ └── ic_bundel_launcher.xml
│ │ ├── values-night
│ │ └── colors.xml
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── ic_bundel_launcher_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── app_widget_info.xml
│ │ ├── data_extraction_rules.xml
│ │ └── full_backup_rules.xml
│ └── test
│ └── kotlin
│ └── dev
│ └── sebastiano
│ └── bundel
│ ├── onboarding
│ ├── DefaultsFakePreferences.kt
│ └── OnboardingViewModelTest.kt
│ └── schedule
│ └── ScheduleCheckerTest.kt
├── art
├── CWI-logo-horizontal-512.png
├── CWI-logo-horizontal.svg
├── bundel_icon.svg
├── logo_horiz.png
├── logo_horiz@2x.png
├── logo_vert.png
└── logo_vert@2x.png
├── build-config
├── detekt.yml
├── dummy-data
│ └── dummy-google-services.json
└── lint.xml
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── preferences
├── build.gradle.kts
├── consumer-rules.pro
└── src
│ ├── androidTest
│ └── kotlin
│ │ └── dev
│ │ └── sebastiano
│ │ └── bundel
│ │ └── preferences
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── kotlin
│ │ └── dev
│ │ │ └── sebastiano
│ │ │ └── bundel
│ │ │ └── preferences
│ │ │ ├── ActiveDaysViewModel.kt
│ │ │ ├── ActiveTimeRangesViewModel.kt
│ │ │ ├── AppFilterInfo.kt
│ │ │ ├── AppInfo.kt
│ │ │ ├── AppsListScreen.kt
│ │ │ ├── BundelPreferencesSerializer.kt
│ │ │ ├── DataStorePreferences.kt
│ │ │ ├── DebugPreferencesViewModel.kt
│ │ │ ├── DependenciesModel.kt
│ │ │ ├── ExcludedAppsViewModel.kt
│ │ │ ├── Lce.kt
│ │ │ ├── LicensesPreferencesViewModel.kt
│ │ │ ├── LicensesScreen.kt
│ │ │ ├── Preferences.kt
│ │ │ ├── PreferencesModule.kt
│ │ │ ├── PreferencesScreen.kt
│ │ │ ├── SelectDaysDialog.kt
│ │ │ ├── SelectTimeRangesDialog.kt
│ │ │ ├── SharedPrefsMigration.kt
│ │ │ ├── WinteryEasterEggViewModel.kt
│ │ │ └── schedule
│ │ │ ├── DaysScheduleSerializer.kt
│ │ │ ├── HoursScheduleSerializer.kt
│ │ │ └── TimeRangesSchedule.kt
│ ├── proto
│ │ └── dev
│ │ │ └── sebastiano
│ │ │ └── bundel
│ │ │ └── protos
│ │ │ └── BundelPreferences.proto
│ └── res
│ │ ├── drawable
│ │ └── ic_back_24.xml
│ │ └── values
│ │ └── strings.xml
│ └── test
│ └── kotlin
│ └── dev
│ └── sebastiano
│ └── bundel
│ └── preferences
│ └── schedule
│ ├── DaysScheduleSerializerTest.kt
│ ├── TimePickerModelTest.kt
│ ├── TimeRangeTest.kt
│ └── TimeRangesScheduleTest.kt
├── settings.gradle.kts
├── shared-ui
├── build.gradle.kts
├── consumer-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── kotlin
│ └── dev
│ │ └── sebastiano
│ │ └── bundel
│ │ └── ui
│ │ ├── Colors.kt
│ │ ├── Dimens.kt
│ │ ├── System.kt
│ │ ├── Theme.kt
│ │ ├── Typography.kt
│ │ ├── composables
│ │ ├── DaysPicker.kt
│ │ ├── MaterialChip.kt
│ │ ├── TimePickerModel.kt
│ │ ├── TimeRange.kt
│ │ ├── TimeRangeRow.kt
│ │ └── WeekDay.kt
│ │ ├── modifiers
│ │ ├── Modifiers.kt
│ │ ├── overlay
│ │ │ ├── AnimatedOverlay.kt
│ │ │ ├── OverlayModifier.kt
│ │ │ └── StrikethroughOverlay.kt
│ │ └── snowfall
│ │ │ └── SnowfallModifier.kt
│ │ └── resources
│ │ └── Resources.kt
│ └── res
│ ├── drawable-xhdpi
│ └── outline_interests_black_48dp.png
│ ├── drawable
│ ├── ic_android_24.xml
│ ├── ic_bundel_icon.xml
│ ├── ic_default_icon.xml
│ ├── snowflake01.xml
│ ├── snowflake02.xml
│ ├── snowflake03.xml
│ └── snowflake04.xml
│ ├── font
│ ├── inter_bold.ttf
│ ├── inter_medium.ttf
│ ├── inter_regular.ttf
│ ├── podkova_bold.ttf
│ ├── podkova_extrabold.ttf
│ ├── podkova_medium.ttf
│ ├── podkova_regular.ttf
│ └── podkova_semibold.ttf
│ └── values
│ └── strings.xml
└── third-party-notices.md
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | max_line_length = 150
11 | tab_width = 4
12 |
13 | [{*.html,*.js,*.css}]
14 | indent_size = 2
15 |
16 | [*.yml]
17 | indent_size = 2
18 |
19 | [{*.kt,*.kts}]
20 | ktlint_function_signature_body_expression_wrapping = multiline
21 | ktlint_ignore_back_ticked_identifier = true
22 |
23 | [gradlew.bat]
24 | end_of_line = crlf
25 |
26 | [{*.bash,*.sh,*.zsh}]
27 | indent_size = 2
28 | tab_width = 2
29 |
30 | [*.md]
31 | indent_size = 2
32 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [rock3r, hamen]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/buildbot.yml:
--------------------------------------------------------------------------------
1 | name: buildbot
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 |
8 | jobs:
9 | check:
10 | name: Static analysis
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout code
15 | uses: actions/checkout@v3
16 |
17 | - name: Set up JDK 17
18 | uses: actions/setup-java@v3
19 | with:
20 | java-version: 17
21 | distribution: corretto
22 |
23 | - uses: android-actions/setup-android@v2
24 | name: Set up Android SDK
25 |
26 | - name: Check for dependency updates
27 | uses: gradle/gradle-command-action@v2
28 | with:
29 | arguments: app:staticAnalysis app:collectSarifReports --continue
30 |
31 | - name: Upload SARIF to Github using the upload-sarif action
32 | uses: github/codeql-action/upload-sarif@v2
33 | if: ${{ always() }}
34 | with:
35 | sarif_file: app/build/reports/sarif/
36 |
37 | test:
38 | name: Run unit tests
39 | runs-on: ubuntu-latest
40 |
41 | steps:
42 | - name: Checkout code
43 | uses: actions/checkout@v3
44 |
45 | - name: Set up JDK 17
46 | uses: actions/setup-java@v3
47 | with:
48 | java-version: 17
49 | distribution: corretto
50 |
51 | - uses: android-actions/setup-android@v2
52 | name: Set up Android SDK
53 |
54 | - name: Run tests with Gradle
55 | uses: gradle/gradle-command-action@v2
56 | with:
57 | arguments: test --continue
58 |
--------------------------------------------------------------------------------
/.github/workflows/dependencies.yml:
--------------------------------------------------------------------------------
1 | name: dependency updates
2 |
3 | on:
4 | workflow_dispatch:
5 | schedule:
6 | # Every Thursday at 4:32 (note scheduled jobs might be delayed or skipped)
7 | - cron: '32 4 * * 4'
8 |
9 | jobs:
10 | check:
11 | name: Check for updated dependencies
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout code
16 | uses: actions/checkout@v2
17 | with:
18 | ref: main
19 |
20 | - name: Set up JDK 17
21 | uses: actions/setup-java@v3
22 | with:
23 | java-version: 17
24 | distribution: corretto
25 |
26 | - uses: android-actions/setup-android@v2
27 | name: Set up Android SDK
28 |
29 | - name: Check for dependency updates
30 | uses: gradle/gradle-command-action@v2
31 | with:
32 | arguments: versionCatalogUpdate
33 |
34 | # Prevent the change to gradlew to be included in the PR
35 | - name: Revert gradlew change
36 | run: git checkout gradlew
37 |
38 | - name: Create Pull Request
39 | uses: peter-evans/create-pull-request@v3
40 | with:
41 | commit-message: Dependency updates
42 | delete-branch: true
43 | branch: catalog-dependency-updates
44 | title: Dependency updates
45 | body: Here are some suggested updates to dependencies :)
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Seb's .gitignore template
2 | # You can find the most up-to-date version at https://go.sebastiano.dev/gitignore
3 | # Partly based on templates by https://plugins.jetbrains.com/plugin/7495--ignore
4 |
5 | ### Windows template
6 | # Windows thumbnail cache files
7 | Thumbs.db
8 | Thumbs.db:encryptable
9 | ehthumbs.db
10 | ehthumbs_vista.db
11 |
12 | # Dump file
13 | *.stackdump
14 |
15 | # Folder config file
16 | [Dd]esktop.ini
17 |
18 | # Recycle Bin used on file shares
19 | $RECYCLE.BIN/
20 |
21 | # Windows Installer files
22 | *.cab
23 | *.msi
24 | *.msix
25 | *.msm
26 | *.msp
27 |
28 | # Windows shortcuts
29 | *.lnk
30 |
31 | ### macOS template
32 | # General
33 | .DS_Store
34 | .AppleDouble
35 | .LSOverride
36 |
37 | # Icon must end with two \r
38 | Icon
39 |
40 | # Thumbnails
41 | ._*
42 |
43 | # Files that might appear in the root of a volume
44 | .DocumentRevisions-V100
45 | .fseventsd
46 | .Spotlight-V100
47 | .TemporaryItems
48 | .Trashes
49 | .VolumeIcon.icns
50 | .com.apple.timemachine.donotpresent
51 |
52 | # Directories potentially created on remote AFP share
53 | .AppleDB
54 | .AppleDesktop
55 | Network Trash Folder
56 | Temporary Items
57 | .apdisk
58 |
59 | ### Linux template
60 | *~
61 |
62 | # temporary files which can be created if a process still has a handle open of a deleted file
63 | .fuse_hidden*
64 |
65 | # KDE directory preferences
66 | .directory
67 |
68 | # Linux trash folder which might appear on any partition or disk
69 | .Trash-*
70 |
71 | # .nfs files are created when an open file is removed but is still being accessed
72 | .nfs*
73 |
74 | ### JetBrains template
75 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
76 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
77 |
78 | *.iml
79 | *.ipr
80 | *.iws
81 | /.idea/*
82 |
83 | # Exclude non-user-specific stuff
84 | !.idea/.name
85 | !.idea/codeInsightSettings.xml
86 | !.idea/codeStyles/
87 | !.idea/copyright/
88 | !.idea/dataSources.xml
89 | !.idea/detekt.xml
90 | !.idea/encodings.xml
91 | !.idea/externalDependencies.xml
92 | !.idea/file.template.settings.xml
93 | !.idea/fileTemplates/
94 | !.idea/icon.svg
95 | !.idea/inspectionProfiles/
96 | !.idea/runConfigurations/
97 | !.idea/scopes/
98 | !.idea/vcs.xml
99 |
100 | ### Kotlin template
101 | # Compiled class file
102 | *.class
103 |
104 | # Log file
105 | *.log
106 |
107 | # Package Files #
108 | *.jar
109 | *.war
110 | *.nar
111 | *.ear
112 | *.zip
113 | *.tar.gz
114 | *.rar
115 |
116 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
117 | hs_err_pid*
118 |
119 | ### Gradle template
120 | .gradle
121 | build/
122 |
123 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
124 | !gradle/wrapper/gradle-wrapper.jar
125 |
126 | # Cache of project
127 | .gradletasknamecache
128 |
129 | ### Android-specific stuff
130 | # Built application files
131 | *.apk
132 | *.ap_
133 | *.aab
134 | *.apks
135 |
136 | # Files for the Dalvik VM
137 | *.dex
138 |
139 | # Generated files
140 | bin/
141 | gen/
142 |
143 | # Local configuration file (sdk path, etc)
144 | local.properties
145 |
146 | # Google/Firebase secrets
147 | google-services.json
148 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | Bundel
--------------------------------------------------------------------------------
/.idea/codeInsightSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | android.app.LauncherActivity.ListItem
6 | android.graphics.Color
7 | android.graphics.drawable.Icon
8 | android.graphics.fonts.FontFamily
9 | android.inputmethodservice.Keyboard.Row
10 | android.text.layout.Alignment
11 | android.view.RoundedCorner
12 | android.view.Surface
13 | android.widget.GridLayout.Alignment
14 | java.lang.reflect.Modifier
15 | java.nio.file.WatchEvent.Modifier
16 | java.time.format.TextStyle
17 | java.util.Observable
18 | org.w3c.dom.Text
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/detekt.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Run_static_analysis.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | true
19 | true
20 | false
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | This code is provided as-is. We'll fix issues as quickly as possible, but no
6 | warranty of any kind is provided that the project is problem-free. Please do
7 | not use this software in security-critical situations without a proper
8 | analysis and vetting of its sources first from a security standpoint — we
9 | have not done it.
10 |
11 | ## Reporting a Vulnerability
12 |
13 | If you find a vulnerability, please report it as an issue. We'll address it
14 | as soon as we can.
15 |
--------------------------------------------------------------------------------
/app/schemas/dev.sebastiano.bundel.storage.RobertoDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "a6b8eccdab8a0770ff18215822747ac5",
6 | "entities": [
7 | {
8 | "tableName": "notifications",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notification_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `showTimestamp` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `text` TEXT, `title` TEXT, `subText` TEXT, `titleBig` TEXT, `app_package` TEXT NOT NULL, PRIMARY KEY(`notification_id`))",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "notification_id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "uniqueId",
19 | "columnName": "uid",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "timestamp",
25 | "columnName": "timestamp",
26 | "affinity": "INTEGER",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "showTimestamp",
31 | "columnName": "showTimestamp",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "isGroup",
37 | "columnName": "isGroup",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "text",
43 | "columnName": "text",
44 | "affinity": "TEXT",
45 | "notNull": false
46 | },
47 | {
48 | "fieldPath": "title",
49 | "columnName": "title",
50 | "affinity": "TEXT",
51 | "notNull": false
52 | },
53 | {
54 | "fieldPath": "subText",
55 | "columnName": "subText",
56 | "affinity": "TEXT",
57 | "notNull": false
58 | },
59 | {
60 | "fieldPath": "titleBig",
61 | "columnName": "titleBig",
62 | "affinity": "TEXT",
63 | "notNull": false
64 | },
65 | {
66 | "fieldPath": "appPackageName",
67 | "columnName": "app_package",
68 | "affinity": "TEXT",
69 | "notNull": true
70 | }
71 | ],
72 | "primaryKey": {
73 | "columnNames": [
74 | "notification_id"
75 | ],
76 | "autoGenerate": false
77 | },
78 | "indices": [],
79 | "foreignKeys": []
80 | }
81 | ],
82 | "views": [],
83 | "setupQueries": [
84 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
85 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a6b8eccdab8a0770ff18215822747ac5')"
86 | ]
87 | }
88 | }
--------------------------------------------------------------------------------
/app/schemas/dev.sebastiano.bundel.storage.RobertoDatabase/2.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 2,
5 | "identityHash": "c450576f05a6681559dbb9e34f6f5b90",
6 | "entities": [
7 | {
8 | "tableName": "notifications",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notification_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `notification_key` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `showTimestamp` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `text` TEXT, `title` TEXT, `subText` TEXT, `titleBig` TEXT, `app_package` TEXT NOT NULL, PRIMARY KEY(`notification_id`))",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "notification_id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "uniqueId",
19 | "columnName": "uid",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "key",
25 | "columnName": "notification_key",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "timestamp",
31 | "columnName": "timestamp",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "showTimestamp",
37 | "columnName": "showTimestamp",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "isGroup",
43 | "columnName": "isGroup",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "text",
49 | "columnName": "text",
50 | "affinity": "TEXT",
51 | "notNull": false
52 | },
53 | {
54 | "fieldPath": "title",
55 | "columnName": "title",
56 | "affinity": "TEXT",
57 | "notNull": false
58 | },
59 | {
60 | "fieldPath": "subText",
61 | "columnName": "subText",
62 | "affinity": "TEXT",
63 | "notNull": false
64 | },
65 | {
66 | "fieldPath": "titleBig",
67 | "columnName": "titleBig",
68 | "affinity": "TEXT",
69 | "notNull": false
70 | },
71 | {
72 | "fieldPath": "appPackageName",
73 | "columnName": "app_package",
74 | "affinity": "TEXT",
75 | "notNull": true
76 | }
77 | ],
78 | "primaryKey": {
79 | "columnNames": [
80 | "notification_id"
81 | ],
82 | "autoGenerate": false
83 | },
84 | "indices": [],
85 | "foreignKeys": []
86 | }
87 | ],
88 | "views": [],
89 | "setupQueries": [
90 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
91 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c450576f05a6681559dbb9e34f6f5b90')"
92 | ]
93 | }
94 | }
--------------------------------------------------------------------------------
/app/schemas/dev.sebastiano.bundel.storage.RobertoDatabase/3.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 3,
5 | "identityHash": "11e54e451d1270196f5836e37d94cc3d",
6 | "entities": [
7 | {
8 | "tableName": "notifications",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notification_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `notification_key` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `showTimestamp` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `text` TEXT, `title` TEXT, `subText` TEXT, `titleBig` TEXT, `app_package` TEXT NOT NULL, PRIMARY KEY(`notification_id`))",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "notification_id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "uniqueId",
19 | "columnName": "uid",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "key",
25 | "columnName": "notification_key",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "timestamp",
31 | "columnName": "timestamp",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "showTimestamp",
37 | "columnName": "showTimestamp",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "isGroup",
43 | "columnName": "isGroup",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "text",
49 | "columnName": "text",
50 | "affinity": "TEXT",
51 | "notNull": false
52 | },
53 | {
54 | "fieldPath": "title",
55 | "columnName": "title",
56 | "affinity": "TEXT",
57 | "notNull": false
58 | },
59 | {
60 | "fieldPath": "subText",
61 | "columnName": "subText",
62 | "affinity": "TEXT",
63 | "notNull": false
64 | },
65 | {
66 | "fieldPath": "titleBig",
67 | "columnName": "titleBig",
68 | "affinity": "TEXT",
69 | "notNull": false
70 | },
71 | {
72 | "fieldPath": "appPackageName",
73 | "columnName": "app_package",
74 | "affinity": "TEXT",
75 | "notNull": true
76 | }
77 | ],
78 | "primaryKey": {
79 | "autoGenerate": false,
80 | "columnNames": [
81 | "notification_id"
82 | ]
83 | },
84 | "indices": [],
85 | "foreignKeys": []
86 | },
87 | {
88 | "tableName": "apps",
89 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `name` TEXT, PRIMARY KEY(`package_name`))",
90 | "fields": [
91 | {
92 | "fieldPath": "packageName",
93 | "columnName": "package_name",
94 | "affinity": "TEXT",
95 | "notNull": true
96 | },
97 | {
98 | "fieldPath": "name",
99 | "columnName": "name",
100 | "affinity": "TEXT",
101 | "notNull": false
102 | }
103 | ],
104 | "primaryKey": {
105 | "autoGenerate": false,
106 | "columnNames": [
107 | "package_name"
108 | ]
109 | },
110 | "indices": [],
111 | "foreignKeys": []
112 | }
113 | ],
114 | "views": [],
115 | "setupQueries": [
116 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
117 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11e54e451d1270196f5836e37d94cc3d')"
118 | ]
119 | }
120 | }
--------------------------------------------------------------------------------
/app/src/androidTest/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/androidTest/kotlin/dev/sebastiano/bundel/FakePreferences.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel
2 |
3 | import dev.sebastiano.bundel.preferences.Preferences
4 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule
5 | import dev.sebastiano.bundel.ui.composables.WeekDay
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.MutableStateFlow
8 |
9 | @Suppress("NotImplementedDeclaration")
10 | internal class FakePreferences : Preferences {
11 |
12 | val crashlyticsEnabled = MutableStateFlow(false)
13 |
14 | override fun isCrashlyticsEnabled() = crashlyticsEnabled
15 |
16 | override suspend fun setIsCrashlyticsEnabled(enabled: Boolean) {
17 | crashlyticsEnabled.emit(enabled)
18 | }
19 |
20 | override fun isWinteryEasterEggEnabled() = MutableStateFlow(false)
21 |
22 | override suspend fun setWinteryEasterEggEnabled(enabled: Boolean) {
23 | TODO("Not yet implemented")
24 | }
25 |
26 | override fun getExcludedPackages(): Flow> {
27 | TODO("Not yet implemented")
28 | }
29 |
30 | override suspend fun setExcludedPackages(excludedPackages: Set) {
31 | TODO("Not yet implemented")
32 | }
33 |
34 | override suspend fun isOnboardingSeen() = false
35 |
36 | override suspend fun setIsOnboardingSeen(onboardingSeen: Boolean) {
37 | TODO("Not yet implemented")
38 | }
39 |
40 | override fun getDaysSchedule() = MutableStateFlow(emptyMap())
41 |
42 | override suspend fun setDaysSchedule(daysSchedule: Map) {
43 | TODO("Not yet implemented")
44 | }
45 |
46 | override fun getTimeRangesSchedule() = MutableStateFlow(TimeRangesSchedule())
47 |
48 | override suspend fun setTimeRangesSchedule(timeRangesSchedule: TimeRangesSchedule) {
49 | TODO("Not yet implemented")
50 | }
51 |
52 | override fun getSnoozeWindowDurationSeconds() = MutableStateFlow(21)
53 |
54 | override suspend fun setSnoozeWindowDurationSeconds(duration: Int) {
55 | TODO("Not yet implemented")
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/androidTest/kotlin/dev/sebastiano/bundel/RobertoFoldTest.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel
2 |
3 | import android.content.res.Resources
4 | import androidx.activity.ComponentActivity
5 | import androidx.compose.ui.test.junit4.createAndroidComposeRule
6 | import androidx.compose.ui.test.onNodeWithText
7 | import androidx.compose.ui.test.performClick
8 | import androidx.test.espresso.device.EspressoDevice.Companion.onDevice
9 | import androidx.test.espresso.device.action.ScreenOrientation
10 | import androidx.test.espresso.device.rules.ScreenOrientationRule
11 | import androidx.test.espresso.device.setScreenOrientation
12 | import dev.sebastiano.bundel.onboarding.OnboardingScreen
13 | import dev.sebastiano.bundel.onboarding.OnboardingViewModel
14 | import dev.sebastiano.bundel.ui.BundelYouTheme
15 | import org.junit.Rule
16 | import org.junit.Test
17 |
18 | class RobertoFoldTest {
19 |
20 | @get:Rule
21 | val composeTestRule = createAndroidComposeRule()
22 |
23 | @get:Rule
24 | val robRulez = ScreenOrientationRule(ScreenOrientation.PORTRAIT)
25 |
26 | private val resources: Resources
27 | get() = composeTestRule.activity.resources
28 |
29 | @Test
30 | fun run_the_onboarding_yey() {
31 | composeTestRule.setContent {
32 | BundelYouTheme {
33 | OnboardingScreen(
34 | viewModel = OnboardingViewModel(FakePreferences()),
35 | needsPermission = true,
36 | onOpenNotificationPreferencesClick = { /*TODO*/ },
37 | ) { }
38 | }
39 | }
40 |
41 | composeTestRule.onNodeWithText(resources.getString(R.string.next).uppercase())
42 | .assertExists("Next button is missing")
43 | .performClick()
44 |
45 | composeTestRule.onNodeWithText(resources.getString(R.string.onboarding_notifications_permission_title))
46 | .assertExists("Not on next page")
47 |
48 | onDevice().setScreenOrientation(ScreenOrientation.LANDSCAPE)
49 |
50 | composeTestRule.onNodeWithText(resources.getString(R.string.onboarding_notifications_permission_title))
51 | .assertExists("Not on page 2 anymore")
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/androidTest/kotlin/dev/sebastiano/bundel/onboarding/PrepareDevicePermissionsRule.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import android.os.Build
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import androidx.test.uiautomator.UiDevice
6 | import org.junit.rules.TestRule
7 | import org.junit.runner.Description
8 | import org.junit.runners.model.Statement
9 |
10 | internal class PrepareDevicePermissionsRule : TestRule {
11 |
12 | override fun apply(base: Statement, description: Description?): Statement =
13 | object : Statement() {
14 | @Throws(Throwable::class)
15 | override fun evaluate() {
16 | val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
17 |
18 | uiDevice.executeShellCommand("log -p i -t BundelTest Allowing hidden methods...")
19 | val apiVersion = Build.VERSION.SDK_INT
20 | when {
21 | apiVersion == Build.VERSION_CODES.P -> {
22 | uiDevice.executeShellCommand("log -p i -t BundelTest Detected API 28")
23 | uiDevice.executeShellCommand("settings put global hidden_api_policy_pre_p_apps 1")
24 | uiDevice.executeShellCommand("settings put global hidden_api_policy_p_apps 1")
25 | }
26 | apiVersion >= Build.VERSION_CODES.Q -> {
27 | uiDevice.executeShellCommand("log -p i -t BundelTest Detected API >= 29")
28 | uiDevice.executeShellCommand("settings put global hidden_api_policy 1")
29 | }
30 | else -> {
31 | uiDevice.executeShellCommand("log -p i -t BundelTest Nothing to do, API < 28")
32 | }
33 | }
34 |
35 | base.evaluate()
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/androidTest/kotlin/dev/sebastiano/bundel/onboarding/PrepareTestStorageRule.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.uiautomator.UiDevice
5 | import org.junit.rules.TestRule
6 | import org.junit.runner.Description
7 | import org.junit.runners.model.Statement
8 |
9 | internal class PrepareTestStorageRule : TestRule {
10 |
11 | override fun apply(base: Statement, description: Description?): Statement =
12 | object : Statement() {
13 | @Throws(Throwable::class)
14 | override fun evaluate() {
15 | val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
16 |
17 | uiDevice.executeShellCommand("log -p i -t TestStorage Enabling test storage permissions...")
18 | uiDevice.executeShellCommand("appops set androidx.test.services MANAGE_EXTERNAL_STORAGE allow")
19 |
20 | base.evaluate()
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/debug/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/debug/java/dev/sebastiano/bundel/glance/GlanceDebugActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.glance
2 |
3 | import androidx.datastore.preferences.core.mutablePreferencesOf
4 | import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
5 | import androidx.glance.appwidget.GlanceAppWidgetReceiver
6 | import com.google.android.glance.tools.viewer.GlanceSnapshot
7 | import com.google.android.glance.tools.viewer.GlanceViewerActivity
8 |
9 | @OptIn(ExperimentalGlanceRemoteViewsApi::class)
10 | class GlanceDebugActivity : GlanceViewerActivity() {
11 |
12 | override suspend fun getGlanceSnapshot(
13 | receiver: Class,
14 | ): GlanceSnapshot {
15 | return when (receiver) {
16 | BundelAppWidgetReceiver::class.java -> GlanceSnapshot(
17 | instance = CannoliWidget(null),
18 | state = mutablePreferencesOf(),
19 | )
20 | else -> throw IllegalArgumentException("Unknown receiver")
21 | }
22 | }
23 |
24 | override fun getProviders() = listOf(BundelAppWidgetReceiver::class.java)
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/debug/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
11 |
12 |
22 |
23 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
40 |
41 |
44 |
47 |
50 |
51 |
52 |
55 |
56 |
57 |
58 |
59 |
60 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/app/src/main/ic_bundel_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/app/src/main/ic_bundel_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/ApplicationModule.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel
2 |
3 | import android.content.Context
4 | import android.content.pm.PackageManager
5 | import android.content.res.AssetManager
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import kotlinx.serialization.json.Json
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | internal class ApplicationModule {
17 |
18 | @Provides
19 | @Singleton
20 | fun providePackageManager(@ApplicationContext context: Context): PackageManager = context.packageManager
21 |
22 | @Provides
23 | @Singleton
24 | fun provideAssetManager(@ApplicationContext context: Context): AssetManager = context.assets
25 |
26 | @Provides
27 | @Singleton
28 | fun provideJson(): Json = Json.Default
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/BundelApplication.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 | import kotlinx.coroutines.CancellationException
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.SupervisorJob
8 | import timber.log.Timber
9 |
10 | @Suppress("unused") // It's declared in the manifest
11 | @HiltAndroidApp
12 | class BundelApplication : Application(), CoroutineScope {
13 |
14 | override val coroutineContext = SupervisorJob()
15 |
16 | override fun onCreate() {
17 | super.onCreate()
18 |
19 | Timber.plant(Timber.DebugTree())
20 | }
21 |
22 | override fun onTerminate() {
23 | coroutineContext.cancel(CancellationException("Application being terminated"))
24 | super.onTerminate()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/DispatchersModule.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.Dispatchers
9 | import javax.inject.Qualifier
10 | import javax.inject.Singleton
11 |
12 | @Suppress("InjectDispatcher")
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | internal object DispatchersModule {
16 |
17 | @DefaultDispatcher
18 | @Provides
19 | @Singleton
20 | fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
21 |
22 | @IoDispatcher
23 | @Provides
24 | @Singleton
25 | fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
26 |
27 | @MainDispatcher
28 | @Provides
29 | @Singleton
30 | fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
31 | }
32 |
33 | @Retention(AnnotationRetention.BINARY)
34 | @Qualifier
35 | internal annotation class DefaultDispatcher
36 |
37 | @Retention(AnnotationRetention.BINARY)
38 | @Qualifier
39 | internal annotation class IoDispatcher
40 |
41 | @Retention(AnnotationRetention.BINARY)
42 | @Qualifier
43 | internal annotation class MainDispatcher
44 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/glance/BundelAppWidgetReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.glance
2 |
3 | import android.content.Context
4 | import androidx.glance.appwidget.GlanceAppWidget
5 | import androidx.glance.appwidget.GlanceAppWidgetReceiver
6 | import androidx.glance.appwidget.updateAll
7 | import dev.sebastiano.bundel.notifications.BundelNotificationListenerService
8 | import timber.log.Timber
9 |
10 | class BundelAppWidgetReceiver : GlanceAppWidgetReceiver() {
11 |
12 | override val glanceAppWidget: GlanceAppWidget =
13 | CannoliWidget(numberOfItems = BundelNotificationListenerService.activeNotificationsFlow.value.size)
14 |
15 | companion object {
16 |
17 | internal suspend fun Context.updateWidgets(notificationsCount: Int) {
18 | Timber.i("Updating widget. Count: $notificationsCount")
19 | CannoliWidget(notificationsCount).updateAll(this)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/glance/CannoliWidget.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.glance
2 |
3 | import android.content.Context
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.ui.unit.DpSize
6 | import androidx.compose.ui.unit.dp
7 | import androidx.glance.GlanceId
8 | import androidx.glance.GlanceModifier
9 | import androidx.glance.LocalSize
10 | import androidx.glance.action.ActionParameters
11 | import androidx.glance.action.clickable
12 | import androidx.glance.appwidget.GlanceAppWidget
13 | import androidx.glance.appwidget.SizeMode
14 | import androidx.glance.appwidget.action.ActionCallback
15 | import androidx.glance.appwidget.action.actionRunCallback
16 | import androidx.glance.appwidget.provideContent
17 | import androidx.glance.background
18 | import androidx.glance.layout.Alignment
19 | import androidx.glance.layout.Box
20 | import androidx.glance.layout.Column
21 | import androidx.glance.layout.Spacer
22 | import androidx.glance.layout.fillMaxSize
23 | import androidx.glance.layout.height
24 | import androidx.glance.text.Text
25 | import androidx.glance.text.TextStyle
26 | import androidx.glance.unit.ColorProvider
27 | import dev.sebastiano.bundel.glance.BundelAppWidgetReceiver.Companion.updateWidgets
28 | import dev.sebastiano.bundel.notifications.BundelNotificationListenerService
29 | import dev.sebastiano.bundel.ui.BundelGlanceTheme
30 | import kotlinx.coroutines.flow.first
31 | import timber.log.Timber
32 |
33 | internal class CannoliWidget(
34 | val numberOfItems: Int?,
35 | ) : GlanceAppWidget() {
36 |
37 | override val sizeMode = SizeMode.Responsive(
38 | setOf(DpSize(36.dp, 36.dp), DpSize(100.dp, 96.dp)),
39 | )
40 |
41 | override suspend fun provideGlance(context: Context, id: GlanceId) {
42 | provideContent {
43 | BundelGlanceTheme {
44 | Box(
45 | modifier = GlanceModifier.background(MaterialTheme.colorScheme.primaryContainer)
46 | .clickable(actionRunCallback())
47 | .fillMaxSize(),
48 | contentAlignment = Alignment.Center,
49 | ) {
50 | val text = when {
51 | numberOfItems == null -> "⏳"
52 | numberOfItems >= 0 -> numberOfItems.toString()
53 | else -> "💩"
54 | }
55 |
56 | val size = LocalSize.current
57 | Timber.d("Size: $size")
58 | if (size.width >= 100.dp && size.height >= 96.dp) {
59 | Column(
60 | modifier = GlanceModifier.fillMaxSize(),
61 | horizontalAlignment = Alignment.CenterHorizontally,
62 | verticalAlignment = Alignment.CenterVertically,
63 | ) {
64 | Text(text = "I'm big whoa 🏆")
65 | Spacer(modifier = GlanceModifier.height(16.dp))
66 | Text(
67 | text = text,
68 | style = TextStyle(
69 | color = ColorProvider(MaterialTheme.colorScheme.onPrimaryContainer),
70 | fontSize = MaterialTheme.typography.displayMedium.fontSize,
71 | ),
72 | )
73 | }
74 | } else {
75 | Text(
76 | text = text,
77 | style = TextStyle(
78 | color = ColorProvider(MaterialTheme.colorScheme.onPrimaryContainer),
79 | fontSize = MaterialTheme.typography.displayMedium.fontSize,
80 | ),
81 | )
82 | }
83 | }
84 | }
85 | }
86 | }
87 | }
88 |
89 | class MustBeTopLevelBecauseReasonsCallbackClassApi : ActionCallback {
90 |
91 | override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
92 | val notificationsCount = BundelNotificationListenerService.activeNotificationsFlow.first().size
93 | context.updateWidgets(notificationsCount)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/glance/TestWidgetScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.glance
2 |
3 | import androidx.compose.foundation.layout.fillMaxSize
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.tooling.preview.Preview
7 | import androidx.compose.ui.unit.DpSize
8 | import androidx.compose.ui.unit.dp
9 | import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
10 | import com.google.android.glance.appwidget.host.glance.GlanceAppWidgetHostPreview
11 |
12 | @Preview
13 | @OptIn(ExperimentalGlanceRemoteViewsApi::class)
14 | @Composable
15 | fun TestWidgetScreen() {
16 | // The size of the widget
17 | val displaySize = DpSize(200.dp, 200.dp)
18 | // Your GlanceAppWidget instance
19 | val instance = CannoliWidget(null)
20 | // Provide a state depending on the GlanceAppWidget state definition
21 | // val state = preferencesOf(CannoliWidget.countKey to 2)
22 |
23 | GlanceAppWidgetHostPreview(
24 | modifier = Modifier.fillMaxSize(),
25 | glanceAppWidget = instance,
26 | displaySize = displaySize,
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/history/HistoryScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.history
2 |
3 | import android.app.Application
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.lazy.LazyColumn
9 | import androidx.compose.foundation.lazy.itemsIndexed
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.platform.LocalContext
13 | import androidx.compose.ui.tooling.preview.Preview
14 | import androidx.compose.ui.unit.dp
15 | import dev.sebastiano.bundel.notifications.PersistableNotification
16 | import dev.sebastiano.bundel.notificationslist.NotificationItem
17 | import dev.sebastiano.bundel.notificationslist.NotificationsListEmptyState
18 | import dev.sebastiano.bundel.storage.DiskImagesStorage
19 | import dev.sebastiano.bundel.ui.BundelYouTheme
20 | import dev.sebastiano.bundel.ui.singlePadding
21 |
22 | @Preview
23 | @Composable
24 | fun NotificationsHistoryEmptyLightPreview() {
25 | BundelYouTheme {
26 | NotificationsHistoryScreen(persistableNotifications = emptyList())
27 | }
28 | }
29 |
30 | @Preview
31 | @Composable
32 | fun NotificationsHistoryEmptyDarkPreview() {
33 | BundelYouTheme(darkTheme = true) {
34 | NotificationsHistoryScreen(persistableNotifications = emptyList())
35 | }
36 | }
37 |
38 | private val persistableNotification = PersistableNotification(
39 | id = 123,
40 | key = "123",
41 | timestamp = 12345678L,
42 | text = "Hello Ivan",
43 | appInfo = PersistableNotification.SenderAppInfo("com.yeah", "Yeah!"),
44 | )
45 |
46 | @Preview
47 | @Composable
48 | fun NotificationsHistoryLightPreview() {
49 | BundelYouTheme {
50 | NotificationsHistoryScreen(
51 | persistableNotifications = listOf(persistableNotification),
52 | )
53 | }
54 | }
55 |
56 | @Preview
57 | @Composable
58 | fun NotificationsHistoryDarkPreview() {
59 | BundelYouTheme(darkTheme = true) {
60 | NotificationsHistoryScreen(
61 | persistableNotifications = listOf(persistableNotification),
62 | )
63 | }
64 | }
65 |
66 | @Composable
67 | internal fun NotificationsHistoryScreen(
68 | innerPadding: PaddingValues = PaddingValues(0.dp),
69 | persistableNotifications: List,
70 | ) {
71 | if (persistableNotifications.isNotEmpty()) {
72 | Column(
73 | Modifier
74 | .fillMaxSize()
75 | .padding(innerPadding),
76 | ) {
77 | NotificationsLazyColumn(persistableNotifications)
78 | }
79 | } else {
80 | NotificationsListEmptyState(Modifier.padding(innerPadding))
81 | }
82 | }
83 |
84 | @Composable
85 | private fun NotificationsLazyColumn(persistableNotifications: List) {
86 | val imagesStorage = DiskImagesStorage(LocalContext.current.applicationContext as Application)
87 | LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(singlePadding())) {
88 | val items = persistableNotifications.filterNot { it.isGroup }
89 | itemsIndexed(items) { index, notification ->
90 | NotificationItem(notification, imagesStorage, isLastItem = index == items.lastIndex)
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/navigation/NavigationRoute.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.navigation
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.material.icons.Icons
5 | import androidx.compose.material.icons.rounded.History
6 | import androidx.compose.material.icons.rounded.NotificationsActive
7 | import androidx.compose.ui.graphics.vector.ImageVector
8 | import dev.sebastiano.bundel.R
9 |
10 | internal sealed class NavigationRoute(val route: String) {
11 |
12 | object SplashoScreenButWithAWeirdNameNotToTriggerLint : NavigationRoute("benThereDoneThat")
13 |
14 | object OnboardingGraph : NavigationRoute("onboarding") {
15 |
16 | object OnboardingScreen : NavigationRoute("onboarding.screen")
17 | }
18 |
19 | object MainScreenGraph : NavigationRoute("main_screen") {
20 |
21 | object MainScreen : NavigationRoute("main_screen.screen")
22 |
23 | object NotificationsList : NavigationRoute("main_screen.notifications_list"), BottomNavNavigationRoute {
24 |
25 | override val icon: ImageVector = Icons.Rounded.NotificationsActive
26 | override val labelId: Int = R.string.bottom_nav_active_notifications
27 | }
28 |
29 | object History : NavigationRoute("main_screen.history"), BottomNavNavigationRoute {
30 |
31 | override val icon: ImageVector = Icons.Rounded.History
32 | override val labelId: Int = R.string.bottom_nav_history
33 | }
34 | }
35 |
36 | object PreferencesGraph : NavigationRoute("preferences") {
37 |
38 | object PreferencesScreen : NavigationRoute("preferences.screen")
39 |
40 | object SelectApps : NavigationRoute("preferences.select_apps")
41 |
42 | object SelectDays : NavigationRoute("preferences.babbadibuppi")
43 |
44 | object SelectTimeRanges : NavigationRoute("preferences.time-ranges")
45 |
46 | object Licenses : NavigationRoute("preferences.absentFriends")
47 |
48 | object TestWidget : NavigationRoute("preferences.testWidget")
49 | }
50 |
51 | interface BottomNavNavigationRoute {
52 |
53 | val icon: ImageVector
54 |
55 | @get:StringRes
56 | val labelId: Int
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/notifications/ActiveNotification.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.notifications
2 |
3 | import android.app.PendingIntent
4 | import android.graphics.drawable.Icon
5 |
6 | internal data class ActiveNotification(
7 | val persistableNotification: PersistableNotification,
8 | val icons: Icons = Icons(),
9 | val interactions: Interactions = Interactions(),
10 | val isSnoozed: Boolean,
11 | ) {
12 |
13 | val isNotEmpty: Boolean =
14 | persistableNotification.isNotEmpty || icons.isNotEmpty
15 |
16 | fun isClickable() = interactions.main != null
17 |
18 | data class Icons(
19 | val appIcon: Icon? = null,
20 | val small: Icon? = null,
21 | val large: Icon? = null,
22 | val extraLarge: Icon? = null,
23 | ) {
24 |
25 | val isNotEmpty: Boolean = appIcon != null || small != null || large != null || extraLarge != null
26 | }
27 |
28 | data class Interactions(
29 | val main: PendingIntent? = null,
30 | val dismiss: PendingIntent? = null,
31 | val actions: List = emptyList(),
32 | ) {
33 |
34 | data class ActionItem(
35 | val text: CharSequence,
36 | val icon: Icon? = null,
37 | val pendingIntent: PendingIntent? = null,
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/notifications/NotificationServiceHelpers.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("ktlint:standard:filename")
2 |
3 | package dev.sebastiano.bundel.notifications
4 |
5 | import android.content.ComponentName
6 | import android.content.Context
7 | import android.provider.Settings
8 |
9 | internal fun needsNotificationsPermission(context: Context): Boolean {
10 | val pkgName = context.packageName
11 | val enabledListeners = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
12 | ?.split(":")
13 | if (enabledListeners.isNullOrEmpty()) return false
14 |
15 | return enabledListeners
16 | .map { listenerPackageName -> ComponentName.unflattenFromString(listenerPackageName) }
17 | .none { pkgName == it?.packageName }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/notifications/PersistableNotification.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.notifications
2 |
3 | internal data class PersistableNotification(
4 | val id: Int,
5 | val key: String,
6 | val timestamp: Long,
7 | val showTimestamp: Boolean = false,
8 | val isGroup: Boolean = false,
9 | val text: String? = null,
10 | val title: String? = null,
11 | val subText: String? = null,
12 | val titleBig: String? = null,
13 | val appInfo: SenderAppInfo,
14 | ) {
15 |
16 | val uniqueId = "${appInfo.packageName}_${id}_$timestamp"
17 |
18 | val isNotEmpty: Boolean =
19 | timestamp >= 0 &&
20 | (
21 | text?.isNotBlank() == true ||
22 | title?.isNotBlank() == true ||
23 | subText?.isNotBlank() == true ||
24 | titleBig?.isNotBlank() == true
25 | )
26 |
27 | data class SenderAppInfo(
28 | val packageName: String,
29 | val name: String? = null,
30 | val iconPath: String? = null,
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/notifications/StatusBarNotificationExtensions.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.notifications
2 |
3 | import android.app.Notification
4 | import android.app.Notification.EXTRA_SHOW_WHEN
5 | import android.content.Context
6 | import android.content.pm.ApplicationInfo
7 | import android.content.pm.PackageManager
8 | import android.content.res.Resources
9 | import android.graphics.drawable.Icon
10 | import android.service.notification.StatusBarNotification
11 | import com.google.firebase.crashlytics.ktx.crashlytics
12 | import com.google.firebase.ktx.Firebase
13 |
14 | internal fun StatusBarNotification.toActiveNotificationOrNull(context: Context) =
15 | toActiveNotification(context).takeIf { it.isNotEmpty }
16 |
17 | @Suppress("DEPRECATION")
18 | internal fun StatusBarNotification.toActiveNotification(context: Context): ActiveNotification {
19 | val packageManager = context.packageManager
20 | val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
21 |
22 | return ActiveNotification(
23 | persistableNotification = PersistableNotification(
24 | id = id,
25 | key = key,
26 | timestamp = notification.`when`,
27 | showTimestamp = notification.run { `when` != 0L && extras.getBoolean(EXTRA_SHOW_WHEN) },
28 | isGroup = notification.run { groupKey != null && flags and Notification.FLAG_GROUP_SUMMARY != 0 },
29 | text = text,
30 | title = title,
31 | subText = subText,
32 | titleBig = titleBig,
33 | appInfo = extractAppInfo(applicationInfo, packageManager),
34 | ),
35 | icons = extractIcons(applicationInfo),
36 | interactions = extractInteractions(),
37 | isSnoozed = false,
38 | )
39 | }
40 |
41 | @Suppress("DEPRECATION")
42 | private fun StatusBarNotification.extractIcons(applicationInfo: ApplicationInfo) = ActiveNotification.Icons(
43 | appIcon = Icon.createWithResource(packageName, applicationInfo.icon),
44 | small = notification.smallIcon,
45 | large = notification.getLargeIcon(),
46 | extraLarge = notification.extras.getParcelable(Notification.EXTRA_LARGE_ICON_BIG),
47 | )
48 |
49 | private fun StatusBarNotification.extractAppInfo(
50 | applicationInfo: ApplicationInfo,
51 | packageManager: PackageManager,
52 | ): PersistableNotification.SenderAppInfo =
53 | PersistableNotification.SenderAppInfo(
54 | packageName = packageName,
55 | name = if (applicationInfo.labelRes != Resources.ID_NULL) {
56 | packageManager.getResourcesForApplication(applicationInfo).getString(applicationInfo.labelRes)
57 | } else {
58 | Firebase.crashlytics.log("Application ${applicationInfo.packageName} has no label")
59 | applicationInfo.packageName
60 | },
61 | iconPath = null,
62 | )
63 |
64 | private fun StatusBarNotification.extractInteractions() = ActiveNotification.Interactions(
65 | main = notification.contentIntent,
66 | dismiss = notification.deleteIntent,
67 | actions = notification.actions
68 | ?.map { ActiveNotification.Interactions.ActionItem(it.title, it.getIcon(), it.actionIntent) }
69 | .orEmpty(),
70 | )
71 |
72 | internal val StatusBarNotification.text: String?
73 | get() = notification.extras.getString(Notification.EXTRA_TEXT)
74 |
75 | internal val StatusBarNotification.title: String?
76 | get() = notification.extras.getString(Notification.EXTRA_TITLE)
77 |
78 | internal val StatusBarNotification.titleBig: String?
79 | get() = notification.extras.getString(Notification.EXTRA_TITLE_BIG)
80 |
81 | internal val StatusBarNotification.subText: String?
82 | get() = notification.extras.getString(Notification.EXTRA_SUB_TEXT)
83 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/notificationslist/NotificationsListEmptyState.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.notificationslist
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.material.ContentAlpha
9 | import androidx.compose.material.LocalContentAlpha
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.CompositionLocalProvider
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.res.stringResource
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.unit.sp
19 | import dev.sebastiano.bundel.R
20 |
21 | @Composable
22 | internal fun NotificationsListEmptyState(modifier: Modifier = Modifier) {
23 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
24 | Column(
25 | Modifier
26 | .fillMaxSize()
27 | .then(modifier),
28 | horizontalAlignment = Alignment.CenterHorizontally,
29 | verticalArrangement = Arrangement.Center,
30 | ) {
31 | Text(stringResource(R.string.sad_face), fontSize = 72.sp)
32 | Spacer(modifier = Modifier.height(8.dp))
33 | Text(text = stringResource(R.string.notifications_empty_text), style = MaterialTheme.typography.bodySmall)
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/notificationslist/NotificationsListScreen.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.notificationslist
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.PaddingValues
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.lazy.LazyColumn
8 | import androidx.compose.foundation.lazy.items
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.collectAsState
11 | import androidx.compose.runtime.getValue
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.tooling.preview.Preview
15 | import androidx.compose.ui.unit.dp
16 | import androidx.lifecycle.Lifecycle
17 | import androidx.lifecycle.flowWithLifecycle
18 | import dev.sebastiano.bundel.notifications.ActiveNotification
19 | import dev.sebastiano.bundel.notifications.BundelNotificationListenerService
20 | import dev.sebastiano.bundel.notifications.PersistableNotification
21 | import dev.sebastiano.bundel.ui.BundelYouTheme
22 | import dev.sebastiano.bundel.ui.singlePadding
23 |
24 | @Preview
25 | @Composable
26 | fun NotificationsListEmptyLightPreview() {
27 | BundelYouTheme {
28 | NotificationsListScreen(
29 | activeNotifications = emptyList(),
30 | onNotificationClick = {},
31 | onNotificationDismiss = {},
32 | )
33 | }
34 | }
35 |
36 | @Preview
37 | @Composable
38 | fun NotificationsListEmptyDarkPreview() {
39 | BundelYouTheme(darkTheme = true) {
40 | NotificationsListScreen(
41 | activeNotifications = emptyList(),
42 | onNotificationClick = {},
43 | onNotificationDismiss = {},
44 | )
45 | }
46 | }
47 |
48 | private val activeNotification = ActiveNotification(
49 | persistableNotification = PersistableNotification(
50 | id = 123,
51 | key = "123",
52 | timestamp = 12345678L,
53 | text = "Hello Ivan",
54 | appInfo = PersistableNotification.SenderAppInfo("com.yeah", "Yeah!"),
55 | ),
56 | isSnoozed = false,
57 | )
58 |
59 | @Preview
60 | @Composable
61 | fun NotificationsListLightPreview() {
62 | BundelYouTheme {
63 | NotificationsListScreen(
64 | activeNotifications = listOf(activeNotification),
65 | onNotificationClick = {},
66 | onNotificationDismiss = {},
67 | )
68 | }
69 | }
70 |
71 | @Preview
72 | @Composable
73 | fun NotificationsListDarkPreview() {
74 | BundelYouTheme(darkTheme = true) {
75 | NotificationsListScreen(
76 | activeNotifications = listOf(activeNotification),
77 | onNotificationClick = {},
78 | onNotificationDismiss = {},
79 | )
80 | }
81 | }
82 |
83 | @Composable
84 | internal fun NotificationsListScreen(
85 | lifecycle: Lifecycle,
86 | innerPadding: PaddingValues,
87 | onNotificationClick: (notification: ActiveNotification) -> Unit,
88 | onNotificationDismiss: (notification: ActiveNotification) -> Unit,
89 | ) {
90 | val notifications by remember(lifecycle) { BundelNotificationListenerService.activeNotificationsFlow.flowWithLifecycle(lifecycle) }
91 | .collectAsState(emptyList())
92 | NotificationsListScreen(notifications, onNotificationClick, onNotificationDismiss, innerPadding)
93 | }
94 |
95 | @Composable
96 | private fun NotificationsListScreen(
97 | activeNotifications: List,
98 | onNotificationClick: (notification: ActiveNotification) -> Unit,
99 | onNotificationDismiss: (notification: ActiveNotification) -> Unit,
100 | innerPadding: PaddingValues = PaddingValues(0.dp),
101 | ) {
102 | if (activeNotifications.isNotEmpty()) {
103 | NotificationsLazyColumn(
104 | activeNotifications,
105 | Modifier.padding(innerPadding),
106 | onNotificationClick,
107 | onNotificationDismiss,
108 | )
109 | } else {
110 | NotificationsListEmptyState()
111 | }
112 | }
113 |
114 | @Composable
115 | private fun NotificationsLazyColumn(
116 | activeNotifications: List,
117 | modifier: Modifier = Modifier,
118 | onNotificationClick: (ActiveNotification) -> Unit,
119 | onNotificationDismiss: (ActiveNotification) -> Unit,
120 | ) {
121 | LazyColumn(
122 | modifier = Modifier
123 | .fillMaxSize()
124 | .then(modifier),
125 | contentPadding = PaddingValues(singlePadding()),
126 | verticalArrangement = Arrangement.spacedBy(singlePadding()),
127 | ) {
128 | val items = activeNotifications.filterNot { it.persistableNotification.isGroup }
129 | items(items = items, key = { item -> item.persistableNotification.uniqueId }) { notification ->
130 | SnoozeItem(notification, onNotificationClick, onNotificationDismiss)
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/onboarding/DaysSchedulePageState.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import dev.sebastiano.bundel.ui.composables.WeekDay
4 |
5 | internal class DaysSchedulePageState(
6 | val daysSchedule: Map,
7 | val onDayCheckedChange: (day: WeekDay, checked: Boolean) -> Unit,
8 | ) {
9 |
10 | constructor() : this(daysSchedule = WeekDay.values().associate { it to true }, onDayCheckedChange = { _, _ -> })
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/onboarding/IntroPage.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.width
11 | import androidx.compose.foundation.rememberScrollState
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material.Switch
14 | import androidx.compose.material.SwitchDefaults
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Surface
17 | import androidx.compose.material3.Text
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.ui.Alignment
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.res.stringResource
22 | import androidx.compose.ui.semantics.SemanticsProperties
23 | import androidx.compose.ui.semantics.semantics
24 | import androidx.compose.ui.state.ToggleableState
25 | import androidx.compose.ui.text.style.TextAlign
26 | import androidx.compose.ui.tooling.preview.Preview
27 | import androidx.compose.ui.unit.dp
28 | import dev.sebastiano.bundel.R
29 | import dev.sebastiano.bundel.ui.BundelYouTheme
30 | import dev.sebastiano.bundel.ui.singlePadding
31 | import dev.sebastiano.bundel.util.Orientation
32 | import dev.sebastiano.bundel.util.currentOrientation
33 |
34 | @Preview(backgroundColor = 0xFF4CE062, showBackground = true)
35 | @Composable
36 | fun IntroPagePreview() {
37 | BundelYouTheme {
38 | Surface {
39 | val england = IntroPageState()
40 | IntroPage(pageState = england)
41 | }
42 | }
43 | }
44 |
45 | @Preview(backgroundColor = 0xFF4CE062, showBackground = true, widthDp = 822, heightDp = 392)
46 | @Composable
47 | fun IntroPageLandscapePreview() {
48 | BundelYouTheme {
49 | Surface {
50 | val england = IntroPageState()
51 | IntroPage(pageState = england, orientation = Orientation.Landscape)
52 | }
53 | }
54 | }
55 |
56 | internal class IntroPageState(
57 | val crashReportingEnabled: Boolean,
58 | val onCrashlyticsEnabledChanged: (Boolean) -> Unit,
59 | ) {
60 |
61 | constructor() : this(crashReportingEnabled = false, onCrashlyticsEnabledChanged = { })
62 | }
63 |
64 | @Composable
65 | internal fun IntroPage(
66 | pageState: IntroPageState,
67 | orientation: Orientation = currentOrientation(),
68 | ) {
69 | Column(
70 | modifier = Modifier
71 | .onboardingPageModifier(orientation)
72 | .verticalScroll(rememberScrollState()),
73 | horizontalAlignment = Alignment.CenterHorizontally,
74 | verticalArrangement = Arrangement.Top,
75 | ) {
76 | if (orientation == Orientation.Portrait) {
77 | PageTitle(text = stringResource(id = R.string.onboarding_welcome_title), textAlign = TextAlign.Center)
78 | }
79 |
80 | Spacer(Modifier.height(24.dp))
81 |
82 | Text(
83 | text = stringResource(id = R.string.onboarding_blurb),
84 | textAlign = TextAlign.Center,
85 | )
86 |
87 | val spacerHeight = if (orientation == Orientation.Portrait) 24.dp else singlePadding()
88 |
89 | Spacer(modifier = Modifier.height(spacerHeight))
90 |
91 | CrashlyticsSwitch(
92 | crashReportingEnabled = pageState.crashReportingEnabled,
93 | onCrashlyticsEnabledChanged = pageState.onCrashlyticsEnabledChanged,
94 | modifier = Modifier.padding(vertical = singlePadding(), horizontal = 16.dp),
95 | )
96 | }
97 | }
98 |
99 | @Composable
100 | private fun CrashlyticsSwitch(
101 | crashReportingEnabled: Boolean,
102 | onCrashlyticsEnabledChanged: (Boolean) -> Unit,
103 | modifier: Modifier,
104 | ) {
105 | Row(
106 | modifier = Modifier
107 | .clickable { onCrashlyticsEnabledChanged(!crashReportingEnabled) }
108 | .semantics(properties = { set(SemanticsProperties.ToggleableState, ToggleableState(crashReportingEnabled)) })
109 | .then(modifier),
110 | verticalAlignment = Alignment.CenterVertically,
111 | ) {
112 | Switch(
113 | checked = crashReportingEnabled,
114 | onCheckedChange = null,
115 | colors = SwitchDefaults.colors(
116 | uncheckedThumbColor = MaterialTheme.colorScheme.secondary,
117 | uncheckedTrackColor = MaterialTheme.colorScheme.onSecondary,
118 | checkedThumbColor = MaterialTheme.colorScheme.secondary,
119 | checkedTrackColor = MaterialTheme.colorScheme.onSecondary,
120 | ),
121 | )
122 |
123 | Spacer(modifier = Modifier.width(singlePadding()))
124 |
125 | Text(stringResource(R.string.onboarding_enable_crashlytics))
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/onboarding/NotificationsAccessPageState.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.rounded.DoneOutline
11 | import androidx.compose.material3.Button
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.LocalContentColor
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.unit.dp
21 | import dev.sebastiano.bundel.R
22 | import dev.sebastiano.bundel.util.Orientation
23 | import dev.sebastiano.bundel.util.currentOrientation
24 |
25 | internal class NotificationsAccessPageState(
26 | val needsPermission: Boolean,
27 | val onSettingsIntentClick: () -> Unit,
28 | ) {
29 |
30 | constructor() : this(needsPermission = true, onSettingsIntentClick = {})
31 | }
32 |
33 | @Composable
34 | internal fun NotificationsAccessPage(
35 | pageState: NotificationsAccessPageState,
36 | orientation: Orientation = currentOrientation(),
37 | ) {
38 | Column(
39 | modifier = Modifier.onboardingPageModifier(orientation),
40 | horizontalAlignment = Alignment.CenterHorizontally,
41 | verticalArrangement = Arrangement.Top,
42 | ) {
43 | if (orientation == Orientation.Portrait) {
44 | PageTitle(text = stringResource(id = R.string.onboarding_notifications_permission_title))
45 | }
46 |
47 | Spacer(Modifier.height(24.dp))
48 |
49 | if (pageState.needsPermission) {
50 | Text(
51 | text = stringResource(R.string.notifications_permission_explanation),
52 | textAlign = TextAlign.Center,
53 | modifier = Modifier.padding(16.dp),
54 | )
55 |
56 | Spacer(Modifier.height(24.dp))
57 |
58 | Button(onClick = pageState.onSettingsIntentClick) {
59 | Text(stringResource(R.string.button_notifications_access_prompt))
60 | }
61 | } else {
62 | Icon(
63 | imageVector = Icons.Rounded.DoneOutline,
64 | contentDescription = stringResource(R.string.notifications_permission_done_image_content_description),
65 | tint = LocalContentColor.current,
66 | modifier = Modifier
67 | .size(72.dp),
68 | )
69 |
70 | Spacer(Modifier.height(16.dp))
71 |
72 | Text(
73 | text = stringResource(R.string.notifications_permission_all_done),
74 | textAlign = TextAlign.Center,
75 | modifier = Modifier.padding(16.dp),
76 | )
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/onboarding/OnboardingViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.google.firebase.crashlytics.ktx.crashlytics
6 | import com.google.firebase.ktx.Firebase
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import dev.sebastiano.bundel.preferences.Preferences
9 | import dev.sebastiano.bundel.ui.composables.TimeRange
10 | import dev.sebastiano.bundel.ui.composables.WeekDay
11 | import kotlinx.coroutines.flow.first
12 | import kotlinx.coroutines.launch
13 | import timber.log.Timber
14 | import javax.inject.Inject
15 |
16 | @HiltViewModel
17 | internal class OnboardingViewModel @Inject constructor(
18 | private val preferences: Preferences,
19 | ) : ViewModel() {
20 |
21 | val timeRangesScheduleFlow = preferences.getTimeRangesSchedule()
22 | val daysScheduleFlow = preferences.getDaysSchedule()
23 | val crashReportingEnabledFlowrina = preferences.isCrashlyticsEnabled()
24 |
25 | fun setCrashReportingEnabled(enabled: Boolean) {
26 | Timber.d("Crashlytics is enabled: $enabled")
27 | Firebase.crashlytics.setCrashlyticsCollectionEnabled(enabled)
28 |
29 | viewModelScope.launch {
30 | preferences.setIsCrashlyticsEnabled(enabled)
31 | }
32 | }
33 |
34 | fun onDaysScheduleChangeWeekDay(weekDay: WeekDay, active: Boolean) {
35 | Timber.d("Schedule day ${weekDay.name} active changed: $active")
36 |
37 | viewModelScope.launch {
38 | val daysScheduleValue = daysScheduleFlow.first().toMutableMap()
39 | daysScheduleValue[weekDay] = active
40 | preferences.setDaysSchedule(daysScheduleValue)
41 | }
42 | }
43 |
44 | fun onTimeRangesScheduleAddTimeRange() {
45 | Timber.d("Adding time range to schedule")
46 |
47 | viewModelScope.launch {
48 | val newSchedule = timeRangesScheduleFlow.first().appendTimeRange()
49 | preferences.setTimeRangesSchedule(newSchedule)
50 | }
51 | }
52 |
53 | fun onTimeRangesScheduleRemoveTimeRange(timeRange: TimeRange) {
54 | Timber.d("Removing time range from schedule: $timeRange")
55 |
56 | viewModelScope.launch {
57 | val newSchedule = timeRangesScheduleFlow.first().removeRange(timeRange)
58 | preferences.setTimeRangesSchedule(newSchedule)
59 | }
60 | }
61 |
62 | fun onTimeRangesScheduleChangeTimeRange(old: TimeRange, new: TimeRange) {
63 | Timber.d("Changing time range in schedule from: $old, to: $new")
64 |
65 | viewModelScope.launch {
66 | val newSchedule = timeRangesScheduleFlow.first().updateRange(old, new)
67 | preferences.setTimeRangesSchedule(newSchedule)
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/onboarding/SimplePageIndicator.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import androidx.compose.animation.core.animateFloatAsState
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.offset
9 | import androidx.compose.foundation.layout.size
10 | import androidx.compose.foundation.shape.CircleShape
11 | import androidx.compose.material.ContentAlpha
12 | import androidx.compose.material.LocalContentAlpha
13 | import androidx.compose.material.LocalContentColor
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.Stable
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.mutableStateOf
18 | import androidx.compose.runtime.setValue
19 | import androidx.compose.ui.Alignment
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.graphics.Color
22 | import androidx.compose.ui.graphics.Shape
23 | import androidx.compose.ui.platform.LocalDensity
24 | import androidx.compose.ui.unit.Dp
25 | import androidx.compose.ui.unit.IntOffset
26 | import androidx.compose.ui.unit.dp
27 |
28 | @Composable
29 | internal fun SimplePageIndicator(
30 | state: IndicatorState,
31 | modifier: Modifier = Modifier,
32 | activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
33 | inactiveColor: Color = activeColor.copy(ContentAlpha.disabled),
34 | indicatorWidth: Dp = 8.dp,
35 | indicatorHeight: Dp = indicatorWidth,
36 | spacing: Dp = indicatorWidth,
37 | indicatorShape: Shape = CircleShape,
38 | ) {
39 | val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() }
40 | val spacingPx = LocalDensity.current.run { spacing.roundToPx() }
41 |
42 | Box(
43 | modifier = modifier,
44 | contentAlignment = Alignment.CenterStart,
45 | ) {
46 | Row(
47 | horizontalArrangement = Arrangement.spacedBy(spacing),
48 | verticalAlignment = Alignment.CenterVertically,
49 | ) {
50 | val indicatorModifier = Modifier
51 | .size(width = indicatorWidth, height = indicatorHeight)
52 | .background(color = inactiveColor, shape = indicatorShape)
53 |
54 | repeat(state.pageCount) {
55 | Box(indicatorModifier)
56 | }
57 | }
58 |
59 | val scrollPosition by animateFloatAsState(targetValue = state.currentPage.toFloat())
60 | Box(
61 | Modifier
62 | .offset {
63 | IntOffset(
64 | x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(),
65 | y = 0,
66 | )
67 | }
68 | .size(width = indicatorWidth, height = indicatorHeight)
69 | .background(color = activeColor, shape = indicatorShape),
70 | )
71 | }
72 | }
73 |
74 | @Stable
75 | class IndicatorState(
76 | val pageCount: Int,
77 | ) {
78 |
79 | var currentPage by mutableStateOf(0)
80 |
81 | override fun equals(other: Any?): Boolean {
82 | if (this === other) return true
83 | if (javaClass != other?.javaClass) return false
84 |
85 | other as IndicatorState
86 |
87 | if (pageCount != other.pageCount) return false
88 | if (currentPage != other.currentPage) return false
89 |
90 | return true
91 | }
92 |
93 | override fun hashCode(): Int {
94 | var result = pageCount
95 | result = 31 * result + currentPage
96 | return result
97 | }
98 |
99 | override fun toString() = "IndicatorState(pagesCount=$pageCount, currentPage=$currentPage)"
100 | }
101 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/schedule/ScheduleChecker.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.schedule
2 |
3 | import androidx.annotation.IntRange
4 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule
5 | import dev.sebastiano.bundel.ui.composables.WeekDay
6 | import java.time.Duration
7 | import java.time.LocalDateTime
8 | import java.util.concurrent.TimeUnit
9 |
10 | internal object ScheduleChecker {
11 |
12 | private val FIFTEEN_MINUTES_IN_MILLIS = TimeUnit.MINUTES.toMillis(15).toInt()
13 |
14 | fun isSnoozeActive(
15 | now: LocalDateTime,
16 | daysSchedule: Map,
17 | timeRangesSchedule: TimeRangesSchedule,
18 | ): Boolean {
19 | val dayInSchedule = daysSchedule.entries.find { (day, _) -> day.dayOfWeek == now.dayOfWeek }
20 | ?: return false
21 |
22 | if (!dayInSchedule.value) return false
23 |
24 | return timeRangesSchedule.any { it.contains(now.toLocalTime()) }
25 | }
26 |
27 | @IntRange(from = 0)
28 | fun calculateSnoozeDelay(
29 | now: LocalDateTime,
30 | daysSchedule: Map,
31 | timeRangesSchedule: TimeRangesSchedule,
32 | ): Int {
33 | require(isSnoozeActive(now, daysSchedule, timeRangesSchedule)) { "Snoozing is not active now" }
34 |
35 | // TODO allow customising delivery frequency (default: 1h)
36 | val nowTime = now.toLocalTime()
37 | val range = timeRangesSchedule.first { it.contains(nowTime) }
38 | val millisDuration = Duration.between(range.from, nowTime).toMillis().toInt()
39 | return FIFTEEN_MINUTES_IN_MILLIS - millisDuration % FIFTEEN_MINUTES_IN_MILLIS
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/Dao.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy.Companion.REPLACE
6 | import androidx.room.Query
7 | import androidx.room.Transaction
8 | import dev.sebastiano.bundel.storage.model.DbAppInfo
9 | import dev.sebastiano.bundel.storage.model.DbNotification
10 | import dev.sebastiano.bundel.storage.model.DbNotificationWithAppInfo
11 | import kotlinx.coroutines.flow.Flow
12 |
13 | @Dao
14 | internal abstract class Dao {
15 |
16 | @Insert(onConflict = REPLACE)
17 | abstract suspend fun insertNotification(notification: DbNotification)
18 |
19 | @Query("SELECT * FROM notifications ORDER BY timestamp DESC")
20 | @Transaction
21 | abstract fun getNotifications(): Flow>
22 |
23 | @Query("DELETE FROM notifications")
24 | abstract suspend fun clearNotifications()
25 |
26 | @Query("DELETE FROM notifications WHERE timestamp < :olderThan")
27 | abstract suspend fun clearNotifications(olderThan: Long)
28 |
29 | @Query("DELETE FROM notifications WHERE notification_id = :notificationId")
30 | abstract suspend fun deleteNotificationById(notificationId: String)
31 |
32 | @Transaction
33 | open suspend fun deleteNotificationsById(ids: List) {
34 | for (id in ids) {
35 | deleteNotificationById(id)
36 | }
37 | }
38 |
39 | @Insert(onConflict = REPLACE)
40 | abstract suspend fun insertAppInfo(appInfo: DbAppInfo)
41 |
42 | @Query("SELECT * FROM apps ORDER BY name")
43 | abstract fun getAppInfo(): Flow>
44 |
45 | @Query("DELETE FROM apps")
46 | abstract suspend fun clearAppInfo()
47 |
48 | @Query("DELETE FROM apps WHERE package_name = :packageName")
49 | abstract suspend fun deleteAppInfoByPackageName(packageName: String)
50 |
51 | @Transaction
52 | open suspend fun deleteAppInfoByIdByPackageName(packageNames: List) {
53 | for (packageName in packageNames) {
54 | deleteNotificationById(packageName)
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/DataRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage
2 |
3 | import dev.sebastiano.bundel.notifications.ActiveNotification
4 | import dev.sebastiano.bundel.storage.model.DbAppInfo
5 | import dev.sebastiano.bundel.storage.model.DbNotification
6 | import kotlinx.coroutines.flow.map
7 | import javax.inject.Inject
8 | import javax.inject.Singleton
9 |
10 | @Singleton
11 | internal class DataRepository @Inject constructor(
12 | private val database: RobertoDatabase,
13 | private val imagesStorage: ImagesStorage,
14 | ) {
15 |
16 | suspend fun saveNotification(activeNotification: ActiveNotification) {
17 | val sebastianoBrokeIt = database.dao()
18 | sebastianoBrokeIt.insertNotification(DbNotification.from(activeNotification.persistableNotification))
19 | sebastianoBrokeIt.insertAppInfo(DbAppInfo.from(activeNotification.persistableNotification.appInfo))
20 | imagesStorage.saveIconsFrom(activeNotification)
21 | val icon = activeNotification.icons.appIcon
22 | if (icon != null) imagesStorage.saveAppIcon(activeNotification.persistableNotification.appInfo.packageName, icon)
23 | }
24 |
25 | fun getNotificationHistory() =
26 | database.dao()
27 | .getNotifications()
28 | .map {
29 | it.map { (appInfo, notification) ->
30 | val iconPath = imagesStorage.getAppIconPath(notification.appPackageName)
31 |
32 | notification.toPersistableNotification(appInfo, iconPath)
33 | }
34 | }
35 |
36 | suspend fun deleteNotification(notificationUniqueId: String) {
37 | database.dao().deleteNotificationById(notificationUniqueId)
38 | cleanupIconsFor(notificationUniqueId)
39 | }
40 |
41 | suspend fun deleteNotifications(notificationUniqueIds: List) {
42 | database.dao().deleteNotificationsById(notificationUniqueIds)
43 | for (notificationUniqueId in notificationUniqueIds) {
44 | cleanupIconsFor(notificationUniqueId)
45 | }
46 | }
47 |
48 | private suspend fun cleanupIconsFor(notificationUniqueId: String) {
49 | imagesStorage.deleteIconsFor(notificationUniqueId)
50 | // TODO clean up app icons when no more notifications use them
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage
2 |
3 | import android.app.Application
4 | import androidx.room.Room
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.components.SingletonComponent
10 | import dev.sebastiano.bundel.storage.migrations.Migration1to2
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | internal abstract class DatabaseModule {
16 |
17 | @Binds
18 | abstract fun provideImagesStorage(diskImagesStorage: DiskImagesStorage): ImagesStorage
19 |
20 | companion object {
21 | @Singleton
22 | @Provides
23 | fun provideDatabase(application: Application): RobertoDatabase =
24 | Room.databaseBuilder(application, RobertoDatabase::class.java, "roberto")
25 | .addMigrations(Migration1to2)
26 | .build()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/ImagesStorage.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage
2 |
3 | import android.graphics.drawable.Icon
4 | import dev.sebastiano.bundel.notifications.ActiveNotification
5 |
6 | internal interface ImagesStorage {
7 |
8 | suspend fun saveIconsFrom(activeNotification: ActiveNotification)
9 |
10 | fun getIconPath(notificationUniqueId: String, iconSize: NotificationIconSize): String
11 |
12 | suspend fun deleteIconsFor(notificationUniqueId: String)
13 |
14 | suspend fun saveAppIcon(packageName: String, icon: Icon)
15 |
16 | fun getAppIconPath(packageName: String): String
17 |
18 | suspend fun deleteAppIcon(packageName: String)
19 |
20 | suspend fun clear()
21 |
22 | enum class NotificationIconSize(val cacheKey: String) {
23 | SMALL("small"),
24 | LARGE("large"),
25 | EXTRA_LARGE("xlarge"),
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/RobertoDatabase.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage
2 |
3 | import androidx.room.AutoMigration
4 | import androidx.room.Database
5 | import androidx.room.RoomDatabase
6 | import dev.sebastiano.bundel.storage.model.DbAppInfo
7 | import dev.sebastiano.bundel.storage.model.DbNotification
8 |
9 | @Database(
10 | entities = [DbNotification::class, DbAppInfo::class],
11 | version = 3,
12 | exportSchema = true,
13 | autoMigrations = [AutoMigration(from = 2, to = 3)],
14 | )
15 | internal abstract class RobertoDatabase : RoomDatabase() {
16 |
17 | abstract fun dao(): Dao
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/migrations/Migration1to2.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage.migrations
2 |
3 | import androidx.room.migration.Migration
4 | import androidx.sqlite.db.SupportSQLiteDatabase
5 |
6 | internal object Migration1to2 : Migration(1, 2) {
7 | override fun migrate(database: SupportSQLiteDatabase) {
8 | database.execSQL("ALTER TABLE notifications ADD COLUMN notification_key TEXT NOT NULL default ''")
9 | database.execSQL("UPDATE notifications SET notification_key = cast(notification_id as TEXT)")
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/storage/model/DbNotification.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.storage.model
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Embedded
5 | import androidx.room.Entity
6 | import androidx.room.PrimaryKey
7 | import androidx.room.Relation
8 | import dev.sebastiano.bundel.notifications.PersistableNotification
9 |
10 | @Entity(tableName = "apps")
11 | internal data class DbAppInfo(
12 | @ColumnInfo("package_name") @PrimaryKey val packageName: String,
13 | val name: String?,
14 | ) {
15 |
16 | companion object Factory {
17 |
18 | fun from(appInfo: PersistableNotification.SenderAppInfo) = DbAppInfo(
19 | packageName = appInfo.packageName,
20 | name = appInfo.name,
21 | )
22 | }
23 | }
24 |
25 | @Entity(tableName = "notifications")
26 | internal data class DbNotification(
27 | @ColumnInfo(name = "notification_id") @PrimaryKey val id: Int,
28 | @ColumnInfo(name = "uid") val uniqueId: String,
29 | @ColumnInfo(name = "notification_key") val key: String,
30 | val timestamp: Long,
31 | val showTimestamp: Boolean = false,
32 | val isGroup: Boolean = false,
33 | val text: String? = null,
34 | val title: String? = null,
35 | val subText: String? = null,
36 | val titleBig: String? = null,
37 | @ColumnInfo(name = "app_package") val appPackageName: String,
38 | ) {
39 |
40 | fun toPersistableNotification(appInfo: DbAppInfo?, appIconPath: String?) = PersistableNotification(
41 | id = id,
42 | key = key,
43 | timestamp = timestamp,
44 | showTimestamp = showTimestamp,
45 | isGroup = isGroup,
46 | text = text,
47 | title = title,
48 | subText = subText,
49 | titleBig = titleBig,
50 | appInfo = PersistableNotification.SenderAppInfo(
51 | packageName = appPackageName,
52 | name = appInfo?.name,
53 | iconPath = appIconPath,
54 | ),
55 | )
56 |
57 | companion object Factory {
58 |
59 | fun from(notification: PersistableNotification) = DbNotification(
60 | id = notification.id,
61 | key = notification.key,
62 | uniqueId = notification.uniqueId,
63 | timestamp = notification.timestamp,
64 | showTimestamp = notification.showTimestamp,
65 | isGroup = notification.isGroup,
66 | text = notification.text,
67 | title = notification.title,
68 | subText = notification.subText,
69 | titleBig = notification.titleBig,
70 | appPackageName = notification.appInfo.packageName,
71 | )
72 | }
73 | }
74 |
75 | @Entity
76 | internal data class DbNotificationWithAppInfo(
77 | @Relation(parentColumn = "app_package", entityColumn = "package_name") val appInfo: DbAppInfo?,
78 | @Embedded val notification: DbNotification,
79 | )
80 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/ui/NavTransitions.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.ui
2 |
3 | import androidx.compose.animation.AnimatedContentTransitionScope
4 | import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
5 | import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
6 | import androidx.compose.animation.EnterTransition
7 | import androidx.compose.animation.ExitTransition
8 | import androidx.compose.animation.ExperimentalAnimationApi
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.animation.fadeIn
11 | import androidx.compose.animation.fadeOut
12 | import androidx.navigation.NavBackStackEntry
13 | import androidx.navigation.NavDestination
14 | import androidx.navigation.NavDestination.Companion.hierarchy
15 | import androidx.navigation.NavGraph
16 | import dev.sebastiano.bundel.navigation.NavigationRoute
17 |
18 | // Note: these transitions have been kindly donated by Chris Banes, and are the
19 | // same you can find in his app Tivi https://github.com/chrisbanes/tivi
20 |
21 | @ExperimentalAnimationApi
22 | internal fun AnimatedContentTransitionScope<*>.defaultEnterTransition(
23 | initial: NavBackStackEntry,
24 | target: NavBackStackEntry,
25 | ): EnterTransition {
26 | if (initial.destination.route == NavigationRoute.SplashoScreenButWithAWeirdNameNotToTriggerLint.route) {
27 | return fadeIn(tween(durationMillis = 0))
28 | }
29 |
30 | val initialNavGraph = initial.destination.hostNavGraph
31 | val targetNavGraph = target.destination.hostNavGraph
32 | // If we're crossing nav graphs (bottom navigation graphs), we crossfade
33 | if (initialNavGraph.id != targetNavGraph.id) {
34 | return fadeIn()
35 | }
36 | // Otherwise we're in the same nav graph, we can imply a direction
37 | return fadeIn() + slideIntoContainer(Start)
38 | }
39 |
40 | @ExperimentalAnimationApi
41 | internal fun AnimatedContentTransitionScope<*>.defaultExitTransition(
42 | initial: NavBackStackEntry,
43 | target: NavBackStackEntry,
44 | ): ExitTransition {
45 | if (initial.destination.route == NavigationRoute.SplashoScreenButWithAWeirdNameNotToTriggerLint.route) {
46 | return fadeOut(tween(durationMillis = 0))
47 | }
48 |
49 | val initialNavGraph = initial.destination.hostNavGraph
50 | val targetNavGraph = target.destination.hostNavGraph
51 | // If we're crossing nav graphs (bottom navigation graphs), we crossfade
52 | if (initialNavGraph.id != targetNavGraph.id) {
53 | return fadeOut()
54 | }
55 | // Otherwise we're in the same nav graph, we can imply a direction
56 | return fadeOut() + slideOutOfContainer(Start)
57 | }
58 |
59 | private val NavDestination.hostNavGraph: NavGraph
60 | get() = hierarchy.first { it is NavGraph } as NavGraph
61 |
62 | @ExperimentalAnimationApi
63 | internal fun AnimatedContentTransitionScope<*>.defaultPopEnterTransition(): EnterTransition =
64 | fadeIn() + slideIntoContainer(End)
65 |
66 | @ExperimentalAnimationApi
67 | internal fun AnimatedContentTransitionScope<*>.defaultPopExitTransition(): ExitTransition =
68 | fadeOut() + slideOutOfContainer(End)
69 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/util/CurrentOrientation.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.util
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.platform.LocalConfiguration
6 |
7 | @Composable
8 | internal fun currentOrientation(): Orientation =
9 | when (LocalConfiguration.current.orientation) {
10 | Configuration.ORIENTATION_LANDSCAPE -> Orientation.Landscape
11 | else -> Orientation.Portrait
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/util/IconExtensions.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.util
2 |
3 | import android.graphics.drawable.Icon
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.graphics.ImageBitmap
7 | import androidx.compose.ui.graphics.asImageBitmap
8 | import androidx.compose.ui.graphics.painter.BitmapPainter
9 | import androidx.compose.ui.graphics.painter.Painter
10 | import androidx.compose.ui.platform.LocalContext
11 | import androidx.core.graphics.drawable.toBitmap
12 |
13 | @Composable
14 | internal fun Icon.asImageBitmap(): ImageBitmap =
15 | loadDrawable(LocalContext.current)
16 | ?.toBitmap()
17 | ?.asImageBitmap()
18 | ?: error("Unable to load drawable for icon $this")
19 |
20 | @Composable
21 | internal fun rememberIconPainter(icon: Icon?): Painter? {
22 | val bitmap = icon?.asImageBitmap()
23 | return remember(icon) { if (bitmap != null) BitmapPainter(bitmap) else null }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/util/KotlinExt.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.util
2 |
3 | val T.exhaustive: T
4 | get() = this
5 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/sebastiano/bundel/util/Orientation.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.util
2 |
3 | internal enum class Orientation {
4 | Landscape,
5 | Portrait,
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_bundel_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_round_settings_24.xml:
--------------------------------------------------------------------------------
1 |
8 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_bundel_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | #04490E
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | #006e1e
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_bundel_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #4CE062
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Bundel notifications access
4 | Give us access
5 | Bundel
6 | In order for the app to work, it requires access to the notifications.
7 | 😢
8 | Nothing yet…
9 | Icon
10 | No icon
11 | %s icon
12 | Active notifications
13 | History
14 | Set your working hours, and Bundel will make sure you’re never interrupted — unless it’s really important.\n\nYou can always tell Bundel to let that special someone through.
15 | Next
16 | Enable crash reporting
17 | Back
18 | Done
19 | All done
20 | Thanks, Bundel has been granted access to the notifications.
21 | All set!
22 | Bundel is ready to help you stay focused. Dive in!
23 | Set your working hours. Bundel will do the rest to make sure you aren’t interrupted.
24 | Notifications access
25 | Welcome to Bundel
26 | Work schedule
27 | For most, these should be your workdays. But you could also use Bundel to make sure you have your “me time” when you’re not working.
28 | The floor is misaligned.
29 | ROBERTO RULES
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/app_widget_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | ./
5 |
6 |
7 |
8 | ./
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/full_backup_rules.xml:
--------------------------------------------------------------------------------
1 |
2 | ./
3 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/dev/sebastiano/bundel/onboarding/DefaultsFakePreferences.kt:
--------------------------------------------------------------------------------
1 | package dev.sebastiano.bundel.onboarding
2 |
3 | import dev.sebastiano.bundel.preferences.DataStorePreferences
4 | import dev.sebastiano.bundel.preferences.Preferences
5 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule
6 | import dev.sebastiano.bundel.ui.composables.WeekDay
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.flow
9 |
10 | internal object DefaultsFakePreferences : Preferences {
11 |
12 | override fun isCrashlyticsEnabled(): Flow = flow {
13 | emit(false)
14 | }
15 |
16 | override suspend fun setIsCrashlyticsEnabled(enabled: Boolean) {
17 | // No-op
18 | }
19 |
20 | override fun isWinteryEasterEggEnabled(): Flow = flow {
21 | emit(false)
22 | }
23 |
24 | override suspend fun setWinteryEasterEggEnabled(enabled: Boolean) {
25 | // No-op
26 | }
27 |
28 | override fun getExcludedPackages(): Flow> = flow {
29 | emit(emptySet())
30 | }
31 |
32 | override suspend fun setExcludedPackages(excludedPackages: Set) {
33 | // No-op
34 | }
35 |
36 | override suspend fun isOnboardingSeen() = true
37 |
38 | override suspend fun setIsOnboardingSeen(onboardingSeen: Boolean) {
39 | // No-op
40 | }
41 |
42 | override fun getDaysSchedule(): Flow