├── .github └── workflows │ ├── android_ci.yml │ └── ci-release-artifacts.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── icon.png ├── icon_dark.png ├── inspectionProfiles │ └── Project_Default.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── dev.sasikanth.pinnit.data.AppDatabase │ │ ├── 1.json │ │ └── 2.json └── src │ ├── androidTest │ └── java │ │ └── dev │ │ └── sasikanth │ │ └── pinnit │ │ ├── AndroidTestRunner.kt │ │ ├── CanaryAndroidTest.kt │ │ ├── SqliteHelperFunctions.kt │ │ ├── data │ │ └── migrations │ │ │ └── Migration2Test.kt │ │ ├── di │ │ └── TestAppModule.kt │ │ └── notifications │ │ └── NotificationsRepositoryAndroidTest.kt │ ├── debug │ └── res │ │ ├── values-night │ │ └── floats.xml │ │ ├── values │ │ └── strings.xml │ │ └── xml-v25 │ │ └── shortcuts.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── dev │ │ │ └── sasikanth │ │ │ └── pinnit │ │ │ ├── AppActionsReceiverActivity.kt │ │ │ ├── PinnitApp.kt │ │ │ ├── ShortcutReceiverActivity.kt │ │ │ ├── SplashActivity.kt │ │ │ ├── about │ │ │ └── AboutBottomSheet.kt │ │ │ ├── activity │ │ │ └── MainActivity.kt │ │ │ ├── background │ │ │ ├── receivers │ │ │ │ ├── AppUpdateReceiver.kt │ │ │ │ ├── BootCompletedReceiver.kt │ │ │ │ ├── DeleteNotificationReceiver.kt │ │ │ │ └── UnpinNotificationReceiver.kt │ │ │ └── services │ │ │ │ └── NotificationsListener.kt │ │ │ ├── data │ │ │ ├── AppDatabase.kt │ │ │ ├── PinnitNotification.kt │ │ │ ├── Schedule.kt │ │ │ ├── migrations │ │ │ │ └── Migration_1_2.kt │ │ │ └── preferences │ │ │ │ ├── AppPreferencesDataStore.kt │ │ │ │ └── AppPreferencesSerializer.kt │ │ │ ├── di │ │ │ ├── AppModule.kt │ │ │ ├── DateTimeFormat.kt │ │ │ ├── DateTimeFormatterModule.kt │ │ │ ├── PreferencesModule.kt │ │ │ └── WorkManagerModule.kt │ │ │ ├── editor │ │ │ ├── CurrentDateValidator.kt │ │ │ ├── EditorScreen.kt │ │ │ ├── EditorScreenEffect.kt │ │ │ ├── EditorScreenEffectHandler.kt │ │ │ ├── EditorScreenEvent.kt │ │ │ ├── EditorScreenInit.kt │ │ │ ├── EditorScreenModel.kt │ │ │ ├── EditorScreenUi.kt │ │ │ ├── EditorScreenUiRender.kt │ │ │ ├── EditorScreenUpdate.kt │ │ │ ├── EditorScreenViewEffect.kt │ │ │ ├── EditorScreenViewModel.kt │ │ │ ├── EditorTransition.kt │ │ │ └── ScheduleValidator.kt │ │ │ ├── notifications │ │ │ ├── NotificationModule.kt │ │ │ ├── NotificationRepository.kt │ │ │ ├── NotificationScreenViewEffect.kt │ │ │ ├── NotificationsScreen.kt │ │ │ ├── NotificationsScreenEffect.kt │ │ │ ├── NotificationsScreenEffectHandler.kt │ │ │ ├── NotificationsScreenEvent.kt │ │ │ ├── NotificationsScreenInit.kt │ │ │ ├── NotificationsScreenModel.kt │ │ │ ├── NotificationsScreenUiRender.kt │ │ │ ├── NotificationsScreenUpdate.kt │ │ │ ├── NotificationsScreenViewModel.kt │ │ │ └── adapter │ │ │ │ ├── NotificationPinItemAnimator.kt │ │ │ │ ├── NotificationsDiffCallback.kt │ │ │ │ ├── NotificationsItemTouchHelper.kt │ │ │ │ └── NotificationsListAdapter.kt │ │ │ ├── oemwarning │ │ │ ├── OemChecker.kt │ │ │ └── OemWarningDialog.kt │ │ │ ├── options │ │ │ └── OptionsBottomSheet.kt │ │ │ ├── scheduler │ │ │ └── PinnitNotificationScheduler.kt │ │ │ ├── utils │ │ │ ├── ContextExt.kt │ │ │ ├── DatabaseExt.kt │ │ │ ├── DispatcherProvider.kt │ │ │ ├── DrawableExt.kt │ │ │ ├── Globals.kt │ │ │ ├── UserClock.kt │ │ │ ├── UtcClock.kt │ │ │ ├── ViewExt.kt │ │ │ ├── notification │ │ │ │ └── NotificationUtil.kt │ │ │ └── room │ │ │ │ ├── JavaTimeRoomTypeConverters.kt │ │ │ │ └── UuidRoomTypeConverter.kt │ │ │ ├── widgets │ │ │ ├── CheckableImageView.kt │ │ │ └── PinnitBottomBar.kt │ │ │ └── worker │ │ │ └── ScheduleWorker.kt │ ├── proto │ │ └── app_preferences.proto │ └── res │ │ ├── anim │ │ ├── pinnit_bottom_sheet_slide_in.xml │ │ └── pinnit_bottom_sheet_slide_out.xml │ │ ├── color │ │ ├── about_item.xml │ │ ├── button_group_background_state.xml │ │ ├── button_group_stroke_state.xml │ │ ├── button_group_text_state.xml │ │ ├── color_notification_divider.xml │ │ ├── color_on_background_15.xml │ │ ├── color_on_background_70.xml │ │ ├── color_secondary_20.xml │ │ ├── color_secondary_5.xml │ │ ├── edit_text_stroke_color.xml │ │ ├── material_button_background_tint.xml │ │ ├── material_button_text_color.xml │ │ ├── schedule_checkbox_state.xml │ │ ├── schedule_indicator_icon_state.xml │ │ ├── schedule_indicator_stroke_state.xml │ │ └── schedule_indicator_text_state.xml │ │ ├── drawable-nodpi │ │ └── pinnit_icon.png │ │ ├── drawable-v26 │ │ └── ic_shortcut_create.xml │ │ ├── drawable │ │ ├── about_item_ripple.xml │ │ ├── asld_pin_unpin.xml │ │ ├── avd_add_to_delete.xml │ │ ├── avd_pin_to_pinned.xml │ │ ├── avd_pin_welcome.xml │ │ ├── avd_pinned_to_unpinned.xml │ │ ├── bottom_bar_item_ripple.xml │ │ ├── divider.xml │ │ ├── ic_arrow_back.xml │ │ ├── ic_arrow_drop_down.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_foreground_themed.xml │ │ ├── ic_pinnit_about.xml │ │ ├── ic_pinnit_add.xml │ │ ├── ic_pinnit_app_icon.xml │ │ ├── ic_pinnit_dark_mode.xml │ │ ├── ic_pinnit_date.xml │ │ ├── ic_pinnit_delete.xml │ │ ├── ic_pinnit_email.xml │ │ ├── ic_pinnit_notification.xml │ │ ├── ic_pinnit_pin.xml │ │ ├── ic_pinnit_pinned.xml │ │ ├── ic_pinnit_source_code.xml │ │ ├── ic_pinnit_warning.xml │ │ ├── ic_pinnit_warning_80dp.xml │ │ ├── ic_qs_app_icon.xml │ │ ├── ic_shortcut_create.xml │ │ ├── ic_shortcut_create_background.xml │ │ ├── ic_shortcut_create_foreground.xml │ │ ├── illustration_pinnit_no_notifications.xml │ │ ├── illustration_pinnit_pin.xml │ │ ├── notification_divider.xml │ │ ├── rectangle.xml │ │ └── splash_layout.xml │ │ ├── font │ │ ├── bai_jam_bold.ttf │ │ ├── bai_jam_medium.ttf │ │ ├── bai_jam_regular.ttf │ │ ├── bai_jam_semibold.ttf │ │ ├── jura_bold.xml │ │ └── jura_semibold.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_splash.xml │ │ ├── fragment_notification_editor.xml │ │ ├── fragment_notifications.xml │ │ ├── notifications_list_item.xml │ │ ├── pinnit_bottom_bar.xml │ │ ├── pinnit_oem_warning_dialog.xml │ │ ├── sheet_about.xml │ │ ├── theme_selection_sheet.xml │ │ └── view_schedule.xml │ │ ├── menu │ │ └── notification_schedule.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ └── main_nav_graph.xml │ │ ├── values-es │ │ └── strings-es.xml │ │ ├── values-land │ │ └── dimens.xml │ │ ├── values-night │ │ ├── colors.xml │ │ └── sys_ui.xml │ │ ├── values-pt-rBR │ │ └── strings.xml │ │ ├── values-v27 │ │ ├── styles.xml │ │ └── theme.xml │ │ ├── values │ │ ├── arrays.xml │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── floats.xml │ │ ├── font_certs.xml │ │ ├── pinnit_bottom_bar.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ ├── styles_typography.xml │ │ ├── sys_ui.xml │ │ └── theme.xml │ │ ├── xml-v25 │ │ └── shortcuts.xml │ │ └── xml │ │ └── actions.xml │ └── test │ ├── java │ └── dev │ │ └── sasikanth │ │ └── pinnit │ │ ├── CanaryTest.kt │ │ ├── editor │ │ ├── EditorScreenEffectHandlerTest.kt │ │ ├── EditorScreenInitTest.kt │ │ ├── EditorScreenUiRenderTest.kt │ │ ├── EditorScreenUpdateTest.kt │ │ └── ScheduleValidatorTest.kt │ │ ├── notifications │ │ ├── NotificationsScreenEffectHandlerTest.kt │ │ ├── NotificationsScreenInitTest.kt │ │ ├── NotificationsScreenUiRenderTest.kt │ │ └── NotificationsScreenUpdateTest.kt │ │ ├── oemwarning │ │ └── OemCheckerTest.kt │ │ └── utils │ │ └── TestDispatcherProvider.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── icon.png ├── release └── app-release.gpg ├── renovate.json ├── settings.gradle.kts └── sharedTestCode ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src └── main ├── AndroidManifest.xml └── java └── dev └── sasikanth └── sharedtestcode ├── TestData.kt └── utils ├── TestUserClock.kt └── TestUtcClock.kt /.github/workflows/android_ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ trunk ] 6 | pull_request: 7 | branches: [ trunk ] 8 | 9 | jobs: 10 | unit-tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: set up JDK 17 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: 17 19 | distribution: zulu 20 | cache: 'gradle' 21 | 22 | - name: Unit Tests 23 | run: ./gradlew testDebugUnitTest 24 | 25 | android-tests: 26 | runs-on: ubuntu-latest 27 | timeout-minutes: 30 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Enable KVM group perms 32 | run: | 33 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 34 | sudo udevadm control --reload-rules 35 | sudo udevadm trigger --name-match=kvm 36 | 37 | - name: set up JDK 17 38 | uses: actions/setup-java@v4 39 | with: 40 | java-version: 17 41 | distribution: zulu 42 | cache: 'gradle' 43 | 44 | - name: Android Tests 45 | uses: reactivecircus/android-emulator-runner@v2 46 | with: 47 | api-level: 31 48 | arch: x86_64 49 | disable-animations: true 50 | script: ./gradlew connectedDebugAndroidTest 51 | -------------------------------------------------------------------------------- /.github/workflows/ci-release-artifacts.yml: -------------------------------------------------------------------------------- 1 | name: Android Play Store release builds 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tramline-input: 7 | description: "Tramline input" 8 | required: false 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | env: 14 | TERM: dumb 15 | ORG_GRADLE_PROJECT_PINNIT_KEYSTORE_PASSWORD: ${{ secrets.PINNIT_KEYSTORE_PASSWORD }} 16 | ORG_GRADLE_PROJECT_PINNIT_KEY_PASSWORD: ${{ secrets.PINNIT_KEY_PASSWORD }} 17 | steps: 18 | - name: Configure Tramline 19 | id: tramline 20 | uses: tramlinehq/deploy-action@v0.1.6 21 | with: 22 | input: ${{ github.event.inputs.tramline-input }} 23 | 24 | - name: Setup versionName regardless of how this action is triggered 25 | id: version_name 26 | run: | 27 | WORKFLOW_INPUT=${{ steps.tramline.outputs.version_name }} 28 | VERSION_NAME=${WORKFLOW_INPUT:-"1.0.0"} 29 | echo "ORG_GRADLE_PROJECT_VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV 30 | 31 | - name: Setup versionCode regardless of how this action is triggered 32 | id: version_code 33 | run: | 34 | WORKFLOW_INPUT=${{ steps.tramline.outputs.version_code }} 35 | VERSION_CODE=${WORKFLOW_INPUT:-"1"} 36 | echo "ORG_GRADLE_PROJECT_VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV 37 | 38 | - uses: actions/setup-java@v4 39 | with: 40 | java-version: '17' 41 | distribution: zulu 42 | cache: 'gradle' 43 | 44 | - name: Decrypt secrets 45 | run: gpg --batch --yes --quiet --decrypt --passphrase=${{ secrets.ENCRYPT_KEY }} --output release/app-release.jks release/app-release.gpg 46 | 47 | - name: Build release artifact 48 | run: ./gradlew bundle 49 | 50 | - name: Upload Release Bundle 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: release-aab 54 | path: app/build/outputs/bundle/release/app-release.aab 55 | 56 | - name: Clean secrets 57 | run: | 58 | rm -f release/app-release.jks 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | .idea/jarRepositories.xml 48 | # Android Studio 3 in .gitignore file. 49 | .idea/caches 50 | .idea/modules.xml 51 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 52 | .idea/navEditor.xml 53 | .idea/markdown-navigator.xml 54 | .idea/markdown-navigator-enh.xml 55 | .idea/misc.xml 56 | .idea/compiler.xml 57 | .idea/deploymentTargetDropDown.xml 58 | .idea/kotlinc.xml 59 | .kotlin 60 | 61 | # Keystore files 62 | # Uncomment the following lines if you do not want to check your keystore files in. 63 | #*.jks 64 | #*.keystore 65 | 66 | # External native build folder generated in Android Studio 2.2 and later 67 | .externalNativeBuild 68 | .cxx/ 69 | 70 | # Google Services (e.g. APIs or Firebase) 71 | # google-services.json 72 | 73 | # Freeline 74 | freeline.py 75 | freeline/ 76 | freeline_project_description.json 77 | 78 | # fastlane 79 | fastlane/report.xml 80 | fastlane/Preview.html 81 | fastlane/screenshots 82 | fastlane/test_output 83 | fastlane/readme.md 84 | 85 | # Version control 86 | vcs.xml 87 | 88 | # lint 89 | lint/intermediates/ 90 | lint/generated/ 91 | lint/outputs/ 92 | lint/tmp/ 93 | # lint/reports/ 94 | 95 | # Android Profiling 96 | *.hprof 97 | 98 | *.DS_Store 99 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/.idea/icon.png -------------------------------------------------------------------------------- /.idea/icon_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/.idea/icon_dark.png -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pinnit 2 | 3 | Notification history and pinning 4 | 5 | [![CI](https://github.com/msasikanth/pinnit/actions/workflows/android_ci.yml/badge.svg?branch=trunk)](https://github.com/msasikanth/pinnit/actions/workflows/android_ci.yml) 6 | 7 | ## Development 8 | Pinnit is written entirely in Kotlin & uses latest Android libraries like 9 | - [Architecture Components](https://developer.android.com/topic/libraries/architecture) (Room, Navigation & Lifecycle) 10 | - [Coroutines](https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html) 11 | - [Dagger](https://dagger.dev) for DI 12 | - [Mobius](https://github.com/spotify/mobius/wiki) for state management 13 | 14 | ## Setup 15 | Requires [Android Studio](https://developer.android.com/studio/) 4.0.0 or above 16 | 17 | ### Code style 18 | Project already has a code style. You can find the XML to import [here](https://github.com/msasikanth/pinnit/blob/master/.idea/codeStyles/Project.xml) 19 | 20 | ## Links 21 | Get it on Google Play 22 | 23 | ## Contributors 24 | - [Sasikanth](https://twitter.com/its_sasikanth) - Developer 25 | - [Eduardo Pratti](https://twitter.com/edpratti) - Designer 26 | 27 | ## License 28 | ``` 29 | Copyright 2020 Sasikanth Miriyampalli 30 | 31 | Licensed under the Apache License, Version 2.0 (the "License"); 32 | you may not use this file except in compliance with the License. 33 | You may obtain a copy of the License at 34 | 35 | https://www.apache.org/licenses/LICENSE-2.0 36 | 37 | Unless required by applicable law or agreed to in writing, software 38 | distributed under the License is distributed on an "AS IS" BASIS, 39 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 40 | See the License for the specific language governing permissions and 41 | limitations under the License. 42 | ``` 43 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/schemas/dev.sasikanth.pinnit.data.AppDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "3c8eb899aafaefb747668e4561b87421", 6 | "entities": [ 7 | { 8 | "tableName": "PinnitNotification", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT, `isPinned` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "uuid", 13 | "columnName": "uuid", 14 | "affinity": "TEXT", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "title", 19 | "columnName": "title", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "content", 25 | "columnName": "content", 26 | "affinity": "TEXT", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "isPinned", 31 | "columnName": "isPinned", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "createdAt", 37 | "columnName": "createdAt", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "updatedAt", 43 | "columnName": "updatedAt", 44 | "affinity": "TEXT", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "deletedAt", 49 | "columnName": "deletedAt", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | } 53 | ], 54 | "primaryKey": { 55 | "columnNames": [ 56 | "uuid" 57 | ], 58 | "autoGenerate": false 59 | }, 60 | "indices": [], 61 | "foreignKeys": [] 62 | } 63 | ], 64 | "views": [], 65 | "setupQueries": [ 66 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 67 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3c8eb899aafaefb747668e4561b87421')" 68 | ] 69 | } 70 | } -------------------------------------------------------------------------------- /app/schemas/dev.sasikanth.pinnit.data.AppDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "c975165bcd52149982982bac0ecc22b6", 6 | "entities": [ 7 | { 8 | "tableName": "PinnitNotification", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT, `isPinned` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `scheduleDate` TEXT, `scheduleTime` TEXT, `scheduleType` TEXT, PRIMARY KEY(`uuid`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "uuid", 13 | "columnName": "uuid", 14 | "affinity": "TEXT", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "title", 19 | "columnName": "title", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "content", 25 | "columnName": "content", 26 | "affinity": "TEXT", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "isPinned", 31 | "columnName": "isPinned", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "createdAt", 37 | "columnName": "createdAt", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "updatedAt", 43 | "columnName": "updatedAt", 44 | "affinity": "TEXT", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "deletedAt", 49 | "columnName": "deletedAt", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | }, 53 | { 54 | "fieldPath": "schedule.scheduleDate", 55 | "columnName": "scheduleDate", 56 | "affinity": "TEXT", 57 | "notNull": false 58 | }, 59 | { 60 | "fieldPath": "schedule.scheduleTime", 61 | "columnName": "scheduleTime", 62 | "affinity": "TEXT", 63 | "notNull": false 64 | }, 65 | { 66 | "fieldPath": "schedule.scheduleType", 67 | "columnName": "scheduleType", 68 | "affinity": "TEXT", 69 | "notNull": false 70 | } 71 | ], 72 | "primaryKey": { 73 | "columnNames": [ 74 | "uuid" 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, 'c975165bcd52149982982bac0ecc22b6')" 86 | ] 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/dev/sasikanth/pinnit/AndroidTestRunner.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.runner.AndroidJUnitRunner 6 | import dagger.hilt.android.testing.HiltTestApplication 7 | 8 | class AndroidTestRunner : AndroidJUnitRunner() { 9 | override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { 10 | return super.newApplication(cl, HiltTestApplication::class.java.name, context) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/androidTest/java/dev/sasikanth/pinnit/CanaryAndroidTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import com.google.common.truth.Truth.assertThat 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | @RunWith(AndroidJUnit4::class) 10 | class CanaryAndroidTest { 11 | 12 | @Test 13 | fun testEnvWorks() { 14 | val context = InstrumentationRegistry.getInstrumentation().targetContext 15 | assertThat(context).isNotNull() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/androidTest/java/dev/sasikanth/pinnit/SqliteHelperFunctions.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import android.content.ContentValues 4 | import android.database.Cursor 5 | import android.database.sqlite.SQLiteDatabase 6 | import androidx.sqlite.db.SupportSQLiteDatabase 7 | import com.google.common.truth.Truth 8 | import dev.sasikanth.pinnit.utils.room.LocalDateRoomTypeConverter 9 | import java.time.Instant 10 | import java.time.LocalDate 11 | import java.util.UUID 12 | 13 | private val dateConverter = LocalDateRoomTypeConverter() 14 | 15 | fun Cursor.string(column: String): String? = getString(getColumnIndex(column)) 16 | fun Cursor.boolean(column: String): Boolean? = getInt(getColumnIndex(column)) == 1 17 | fun Cursor.integer(columnName: String): Int? = getInt(getColumnIndex(columnName)) 18 | fun Cursor.long(columnName: String): Long = getLong(getColumnIndex(columnName)) 19 | fun Cursor.double(columnName: String): Double = getDouble(getColumnIndex(columnName)) 20 | fun Cursor.float(columnName: String): Float = getFloat(getColumnIndex(columnName)) 21 | fun Cursor.uuid(columnName: String): UUID? = string(columnName)?.let { UUID.fromString(it) } 22 | fun Cursor.instant(columnName: String): Instant? = string(columnName)?.let { Instant.parse(it) } 23 | fun Cursor.localDate(columnName: String): LocalDate? = string(columnName).let(dateConverter::toLocalDate) 24 | 25 | fun SupportSQLiteDatabase.insert(tableName: String, valuesMap: Map) { 26 | val contentValues = valuesMap 27 | .entries 28 | .fold(ContentValues()) { values, (key, value) -> 29 | when (value) { 30 | null -> values.putNull(key) 31 | is Int -> values.put(key, value) 32 | is Long -> values.put(key, value) 33 | is Float -> values.put(key, value) 34 | is Double -> values.put(key, value) 35 | is Boolean -> values.put(key, value) 36 | is String -> values.put(key, value) 37 | is UUID -> values.put(key, value.toString()) 38 | is Instant -> values.put(key, value.toString()) 39 | is LocalDate -> values.put(key, dateConverter.fromLocalDate(value)) 40 | else -> throw IllegalArgumentException("Unknown type (${value.javaClass.name}) for key: $key") 41 | } 42 | 43 | values 44 | } 45 | 46 | insert(tableName, SQLiteDatabase.CONFLICT_ABORT, contentValues) 47 | } 48 | 49 | fun Cursor.assertValues(valuesMap: Map) { 50 | Truth.assertThat(columnNames.toSet()).containsExactlyElementsIn(valuesMap.keys) 51 | valuesMap 52 | .forEach { (key, value) -> 53 | val withMessage = Truth.assertWithMessage("For column [$key]: ") 54 | when (value) { 55 | null -> withMessage.that(isNull(getColumnIndex(key))).isTrue() 56 | is Int -> withMessage.that(integer(key)).isEqualTo(value) 57 | is Long -> withMessage.that(long(key)).isEqualTo(value) 58 | is Float -> withMessage.that(float(key)).isEqualTo(value) 59 | is Double -> withMessage.that(double(key)).isEqualTo(value) 60 | is Boolean -> withMessage.that(boolean(key)).isEqualTo(value) 61 | is String -> withMessage.that(string(key)).isEqualTo(value) 62 | is UUID -> withMessage.that(uuid(key)).isEqualTo(value) 63 | is Instant -> withMessage.that(instant(key)).isEqualTo(value) 64 | is LocalDate -> withMessage.that(localDate(key)).isEqualTo(value) 65 | else -> throw IllegalArgumentException("Unknown type (${value.javaClass.name}) for key: $key") 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/androidTest/java/dev/sasikanth/pinnit/data/migrations/Migration2Test.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data.migrations 2 | 3 | import androidx.room.testing.MigrationTestHelper 4 | import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import androidx.test.platform.app.InstrumentationRegistry 7 | import dev.sasikanth.pinnit.assertValues 8 | import dev.sasikanth.pinnit.data.AppDatabase 9 | import dev.sasikanth.pinnit.insert 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import java.io.IOException 14 | import java.time.Instant 15 | import java.util.UUID 16 | 17 | private const val TEST_DB = "pinnit-test-db" 18 | 19 | @RunWith(AndroidJUnit4::class) 20 | class Migration2Test { 21 | 22 | @get:Rule 23 | val helper: MigrationTestHelper = MigrationTestHelper( 24 | InstrumentationRegistry.getInstrumentation(), 25 | AppDatabase::class.java, 26 | specs = emptyList(), 27 | FrameworkSQLiteOpenHelperFactory() 28 | ) 29 | 30 | @Test 31 | @Throws(IOException::class) 32 | fun migrate1To2() { 33 | // given 34 | val notificationId = UUID.fromString("4179c32e-3483-4d45-962d-0e58d3b4d960") 35 | var db = helper.createDatabase(TEST_DB, 1) 36 | 37 | db.insert( 38 | "PinnitNotification", mapOf( 39 | "uuid" to notificationId, 40 | "title" to "Sample Title", 41 | "content" to "Sample Content", 42 | "isPinned" to true, 43 | "createdAt" to Instant.parse("2020-01-01T00:00:00.00Z"), 44 | "updatedAt" to Instant.parse("2020-01-01T00:00:00.00Z"), 45 | "deletedAt" to null 46 | ) 47 | ) 48 | 49 | // Prepare for the next version. 50 | db.close() 51 | 52 | // when 53 | db = helper.runMigrationsAndValidate(TEST_DB, 2, true, Migration_1_2) 54 | 55 | // then 56 | db.query(""" SELECT * FROM PinnitNotification """).use { cursor -> 57 | cursor.moveToFirst() 58 | cursor.assertValues( 59 | mapOf( 60 | "uuid" to notificationId, 61 | "title" to "Sample Title", 62 | "content" to "Sample Content", 63 | "isPinned" to true, 64 | "createdAt" to Instant.parse("2020-01-01T00:00:00.00Z"), 65 | "updatedAt" to Instant.parse("2020-01-01T00:00:00.00Z"), 66 | "deletedAt" to null, 67 | "scheduleDate" to null, 68 | "scheduleTime" to null, 69 | "scheduleType" to null 70 | ) 71 | ) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/androidTest/java/dev/sasikanth/pinnit/di/TestAppModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.spotify.mobius.runners.ImmediateWorkRunner 6 | import com.spotify.mobius.runners.WorkRunner 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import dagger.hilt.testing.TestInstallIn 12 | import dev.sasikanth.pinnit.data.AppDatabase 13 | import dev.sasikanth.pinnit.utils.CoroutineDispatcherProvider 14 | import dev.sasikanth.pinnit.utils.DispatcherProvider 15 | import dev.sasikanth.pinnit.utils.UserClock 16 | import dev.sasikanth.pinnit.utils.UtcClock 17 | import dev.sasikanth.sharedtestcode.utils.TestUserClock 18 | import dev.sasikanth.sharedtestcode.utils.TestUtcClock 19 | import java.time.ZoneId 20 | import javax.inject.Singleton 21 | 22 | @TestInstallIn( 23 | components = [SingletonComponent::class], 24 | replaces = [AppModule::class] 25 | ) 26 | @Module 27 | object TestAppModule { 28 | 29 | @Singleton 30 | @Provides 31 | fun providesTestAppDatabase( 32 | @ApplicationContext context: Context 33 | ): AppDatabase { 34 | return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) 35 | .build() 36 | } 37 | 38 | @Singleton 39 | @Provides 40 | fun providesTestUtcClock(): TestUtcClock = TestUtcClock() 41 | 42 | @Singleton 43 | @Provides 44 | fun providesUtcClock(testUtcClock: TestUtcClock): UtcClock = testUtcClock 45 | 46 | @Singleton 47 | @Provides 48 | fun testUserClock(): TestUserClock { 49 | return TestUserClock() 50 | } 51 | 52 | @Singleton 53 | @Provides 54 | fun userClock(testUserClock: TestUserClock): UserClock = testUserClock 55 | 56 | @Singleton 57 | @Provides 58 | fun providesDispatcherProvider(): DispatcherProvider = CoroutineDispatcherProvider() 59 | 60 | @Singleton 61 | @Provides 62 | fun providesSystemDefaultZone(): ZoneId = ZoneId.systemDefault() 63 | 64 | @Singleton 65 | @Provides 66 | fun providesWorkRunner(): WorkRunner = ImmediateWorkRunner() 67 | } 68 | -------------------------------------------------------------------------------- /app/src/debug/res/values-night/floats.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0.8 4 | 5 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Pinnit (D) 3 | 4 | -------------------------------------------------------------------------------- /app/src/debug/res/xml-v25/shortcuts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/AppActionsReceiverActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.navigation.NavDeepLinkBuilder 6 | import dev.sasikanth.pinnit.activity.MainActivity 7 | import dev.sasikanth.pinnit.editor.EditorScreenArgs 8 | import dev.sasikanth.pinnit.editor.EditorTransition 9 | 10 | class AppActionsReceiverActivity : AppCompatActivity() { 11 | 12 | companion object { 13 | const val MESSAGE = "message" 14 | } 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | 19 | val receivedText = intent?.data?.getQueryParameter(MESSAGE) 20 | val editorScreenArgs = EditorScreenArgs( 21 | notificationTitle = receivedText, 22 | editorTransition = EditorTransition.SharedAxis 23 | ).toBundle() 24 | 25 | // Launch the deep link to editor page 26 | NavDeepLinkBuilder(this) 27 | .setComponentName(MainActivity::class.java) 28 | .setDestination(R.id.editorScreen) 29 | .setGraph(R.navigation.main_nav_graph) 30 | .setArguments(editorScreenArgs) 31 | .createTaskStackBuilder() 32 | .startActivities() 33 | 34 | // finish the activity once necessary actions are performed 35 | finish() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/PinnitApp.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import android.app.Application 4 | import android.os.StrictMode 5 | import android.os.StrictMode.ThreadPolicy 6 | import android.os.StrictMode.VmPolicy 7 | import androidx.appcompat.app.AppCompatDelegate 8 | import androidx.datastore.core.DataStore 9 | import androidx.work.Configuration 10 | import dagger.hilt.android.HiltAndroidApp 11 | import dev.sasikanth.pinnit.data.preferences.AppPreferences 12 | import dev.sasikanth.pinnit.utils.DispatcherProvider 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.flow.launchIn 15 | import kotlinx.coroutines.flow.map 16 | import kotlinx.coroutines.flow.onEach 17 | import javax.inject.Inject 18 | 19 | 20 | @HiltAndroidApp 21 | class PinnitApp : Application(), Configuration.Provider { 22 | 23 | @Inject 24 | lateinit var appPreferencesStore: DataStore 25 | 26 | @Inject 27 | lateinit var configuration: Configuration 28 | 29 | @Inject 30 | lateinit var dispatcherProvider: DispatcherProvider 31 | 32 | private val mainScope by lazy { 33 | CoroutineScope(dispatcherProvider.main) 34 | } 35 | 36 | override val workManagerConfiguration: Configuration 37 | get() = configuration 38 | 39 | override fun onCreate() { 40 | if (BuildConfig.DEBUG) { 41 | StrictMode.setThreadPolicy( 42 | ThreadPolicy.Builder() 43 | .detectDiskReads() 44 | .detectDiskWrites() 45 | .penaltyLog() 46 | .build() 47 | ) 48 | StrictMode.setVmPolicy( 49 | VmPolicy.Builder() 50 | .detectLeakedSqlLiteObjects() 51 | .detectLeakedClosableObjects() 52 | .penaltyLog() 53 | .penaltyDeath() 54 | .build() 55 | ) 56 | } 57 | 58 | super.onCreate() 59 | 60 | appPreferencesStore 61 | .data 62 | .map { it.theme } 63 | .onEach(::setAppTheme) 64 | .launchIn(mainScope) 65 | } 66 | 67 | private fun setAppTheme(theme: AppPreferences.Theme) = when (theme) { 68 | AppPreferences.Theme.DARK -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) 69 | AppPreferences.Theme.LIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 70 | AppPreferences.Theme.AUTO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) 71 | else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/ShortcutReceiverActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import android.content.Intent.EXTRA_TEXT 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.navigation.NavDeepLinkBuilder 7 | import dev.sasikanth.pinnit.activity.MainActivity 8 | import dev.sasikanth.pinnit.editor.EditorScreenArgs 9 | import dev.sasikanth.pinnit.editor.EditorTransition.SharedAxis 10 | 11 | class ShortcutReceiverActivity : AppCompatActivity() { 12 | 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | 16 | val receivedText = intent.getStringExtra(EXTRA_TEXT) 17 | val editorScreenArgs = EditorScreenArgs( 18 | notificationContent = receivedText, 19 | editorTransition = SharedAxis 20 | ).toBundle() 21 | 22 | // Launch the deep link to editor page 23 | NavDeepLinkBuilder(this) 24 | .setComponentName(MainActivity::class.java) 25 | .setDestination(R.id.editorScreen) 26 | .setGraph(R.navigation.main_nav_graph) 27 | .setArguments(editorScreenArgs) 28 | .createTaskStackBuilder() 29 | .startActivities() 30 | 31 | // finish the activity once necessary actions are performed 32 | finish() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import android.content.Intent 4 | import android.graphics.drawable.Drawable 5 | import android.os.Bundle 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.core.view.doOnLayout 8 | import androidx.vectordrawable.graphics.drawable.Animatable2Compat 9 | import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat 10 | import dev.chrisbanes.insetter.setEdgeToEdgeSystemUiFlags 11 | import dev.sasikanth.pinnit.activity.MainActivity 12 | import dev.sasikanth.pinnit.databinding.ActivitySplashBinding 13 | 14 | class SplashActivity : AppCompatActivity() { 15 | 16 | private val animationCallback = object : Animatable2Compat.AnimationCallback() { 17 | override fun onAnimationEnd(drawable: Drawable?) { 18 | super.onAnimationEnd(drawable) 19 | val intent = Intent(this@SplashActivity, MainActivity::class.java) 20 | startActivity(intent) 21 | finish() 22 | } 23 | } 24 | 25 | private var animatedWelcomeImage: AnimatedVectorDrawableCompat? = null 26 | 27 | private lateinit var binding: ActivitySplashBinding 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | binding = ActivitySplashBinding.inflate(layoutInflater) 32 | setContentView(binding.root) 33 | 34 | binding.rootView.setEdgeToEdgeSystemUiFlags() 35 | 36 | animatedWelcomeImage = AnimatedVectorDrawableCompat.create(this, R.drawable.avd_pin_welcome) 37 | 38 | binding.welcomeImage.apply { 39 | setImageDrawable(animatedWelcomeImage) 40 | doOnLayout { 41 | animatedWelcomeImage?.start() 42 | animatedWelcomeImage?.registerAnimationCallback(animationCallback) 43 | } 44 | } 45 | } 46 | 47 | override fun onDestroy() { 48 | animatedWelcomeImage?.unregisterAnimationCallback(animationCallback) 49 | super.onDestroy() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/about/AboutBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.about 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Build 6 | import android.os.Bundle 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import androidx.core.view.isVisible 11 | import androidx.fragment.app.FragmentManager 12 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment 13 | import dev.sasikanth.pinnit.BuildConfig 14 | import dev.sasikanth.pinnit.R 15 | import dev.sasikanth.pinnit.databinding.SheetAboutBinding 16 | import dev.sasikanth.pinnit.oemwarning.OemWarningDialog 17 | import dev.sasikanth.pinnit.oemwarning.shouldShowWarningForOEM 18 | 19 | class AboutBottomSheet : BottomSheetDialogFragment() { 20 | 21 | companion object { 22 | private const val TAG = "ABOUT_BOTTOM_SHEET" 23 | private const val PINNIT_PROJECT_URL = "https://github.com/msasikanth/pinnit" 24 | 25 | fun show(fragmentManager: FragmentManager) { 26 | AboutBottomSheet().show(fragmentManager, TAG) 27 | } 28 | } 29 | 30 | private var _binding: SheetAboutBinding? = null 31 | private val binding get() = _binding!! 32 | 33 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 34 | _binding = SheetAboutBinding.inflate(layoutInflater, container, false) 35 | return _binding?.root 36 | } 37 | 38 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 39 | super.onViewCreated(view, savedInstanceState) 40 | 41 | setAppVersion() 42 | 43 | // binding.contactSupportButton.setOnClickListener { sendSupportEmail() } 44 | binding.sourceCodeButton.setOnClickListener { openGitHubProject() } 45 | 46 | setupShowOemWarning() 47 | } 48 | 49 | override fun onDestroyView() { 50 | _binding = null 51 | super.onDestroyView() 52 | } 53 | 54 | private fun setupShowOemWarning() { 55 | val brandName = Build.BRAND 56 | val shouldShowOemWarning = shouldShowWarningForOEM(brandName) 57 | binding.dividerView.isVisible = shouldShowOemWarning 58 | binding.oemWarningButton.isVisible = shouldShowOemWarning 59 | 60 | binding.oemWarningButton.setOnClickListener { 61 | dismiss() 62 | OemWarningDialog.show(requireActivity().supportFragmentManager) 63 | } 64 | } 65 | 66 | private fun setAppVersion() { 67 | val versionName = BuildConfig.VERSION_NAME 68 | binding.appVersionTextView.text = getString(R.string.app_version, versionName) 69 | } 70 | 71 | private fun openGitHubProject() { 72 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(PINNIT_PROJECT_URL)) 73 | startActivity(intent) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.activity 2 | 3 | import android.os.Build 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.datastore.core.DataStore 7 | import androidx.lifecycle.Lifecycle 8 | import androidx.lifecycle.lifecycleScope 9 | import androidx.lifecycle.repeatOnLifecycle 10 | import androidx.navigation.NavController 11 | import androidx.navigation.fragment.NavHostFragment 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import dev.chrisbanes.insetter.setEdgeToEdgeSystemUiFlags 14 | import dev.sasikanth.pinnit.R 15 | import dev.sasikanth.pinnit.data.preferences.AppPreferences 16 | import dev.sasikanth.pinnit.databinding.ActivityMainBinding 17 | import dev.sasikanth.pinnit.oemwarning.OemWarningDialog 18 | import dev.sasikanth.pinnit.oemwarning.shouldShowWarningForOEM 19 | import dev.sasikanth.pinnit.utils.DispatcherProvider 20 | import kotlinx.coroutines.flow.first 21 | import kotlinx.coroutines.launch 22 | import kotlinx.coroutines.withContext 23 | import java.util.Locale 24 | import javax.inject.Inject 25 | 26 | @AndroidEntryPoint 27 | class MainActivity : AppCompatActivity() { 28 | 29 | @Inject 30 | lateinit var appPreferencesStore: DataStore 31 | 32 | @Inject 33 | lateinit var dispatcherProvider: DispatcherProvider 34 | 35 | private var navController: NavController? = null 36 | 37 | private lateinit var binding: ActivityMainBinding 38 | 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | 42 | binding = ActivityMainBinding.inflate(layoutInflater) 43 | setContentView(binding.root) 44 | 45 | binding.mainRoot.setEdgeToEdgeSystemUiFlags() 46 | 47 | val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_container) as NavHostFragment 48 | navController = navHostFragment.navController 49 | 50 | lifecycleScope.launch { 51 | repeatOnLifecycle(Lifecycle.State.RESUMED) { 52 | val isOemWarningDialogShown = withContext(dispatcherProvider.io) { 53 | appPreferencesStore.data.first().oemWarningDialog 54 | } 55 | showOemWarningDialog(isOemWarningDialogShown) 56 | } 57 | } 58 | } 59 | 60 | private suspend fun showOemWarningDialog(isOemWarningDialogShown: Boolean) { 61 | val brandName = Build.BRAND.lowercase(Locale.getDefault()) 62 | if (isOemWarningDialogShown.not() && shouldShowWarningForOEM(brandName)) { 63 | appPreferencesStore.updateData { currentData -> 64 | currentData.toBuilder() 65 | .setOemWarningDialog(true) 66 | .build() 67 | } 68 | 69 | OemWarningDialog.show(supportFragmentManager) 70 | } 71 | } 72 | 73 | override fun onDestroy() { 74 | navController = null 75 | super.onDestroy() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/background/receivers/AppUpdateReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.background.receivers 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import dev.sasikanth.pinnit.notifications.NotificationRepository 8 | import dev.sasikanth.pinnit.utils.DispatcherProvider 9 | import dev.sasikanth.pinnit.utils.notification.NotificationUtil 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.SupervisorJob 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | import javax.inject.Inject 15 | 16 | @AndroidEntryPoint 17 | class AppUpdateReceiver : BroadcastReceiver() { 18 | 19 | @Inject 20 | lateinit var repository: NotificationRepository 21 | 22 | @Inject 23 | lateinit var notificationUtil: NotificationUtil 24 | 25 | @Inject 26 | lateinit var dispatcherProvider: DispatcherProvider 27 | 28 | private val mainScope by lazy { 29 | CoroutineScope(SupervisorJob() + dispatcherProvider.main) 30 | } 31 | 32 | override fun onReceive(context: Context?, intent: Intent?) { 33 | if (context != null && intent != null && intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { 34 | val asyncResult = goAsync() 35 | 36 | mainScope.launch { 37 | runCatching { 38 | val pinnedNotifications = repository.pinnedNotifications() 39 | withContext(dispatcherProvider.default) { 40 | notificationUtil.checkNotificationsVisibility(pinnedNotifications) 41 | } 42 | } 43 | asyncResult.finish() 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/background/receivers/BootCompletedReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.background.receivers 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import dev.sasikanth.pinnit.notifications.NotificationRepository 8 | import dev.sasikanth.pinnit.utils.DispatcherProvider 9 | import dev.sasikanth.pinnit.utils.notification.NotificationUtil 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.SupervisorJob 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | import javax.inject.Inject 15 | 16 | @AndroidEntryPoint 17 | class BootCompletedReceiver : BroadcastReceiver() { 18 | 19 | @Inject 20 | lateinit var repository: NotificationRepository 21 | 22 | @Inject 23 | lateinit var notificationUtil: NotificationUtil 24 | 25 | @Inject 26 | lateinit var dispatcherProvider: DispatcherProvider 27 | 28 | private val mainScope by lazy { 29 | CoroutineScope(SupervisorJob() + dispatcherProvider.main) 30 | } 31 | 32 | override fun onReceive(context: Context?, intent: Intent?) { 33 | if (context != null && intent != null && intent.action == Intent.ACTION_BOOT_COMPLETED) { 34 | val asyncResult = goAsync() 35 | 36 | mainScope.launch { 37 | runCatching { 38 | val pinnedNotifications = repository.pinnedNotifications() 39 | withContext(dispatcherProvider.default) { 40 | notificationUtil.checkNotificationsVisibility(pinnedNotifications) 41 | } 42 | } 43 | asyncResult.finish() 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/background/receivers/DeleteNotificationReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.background.receivers 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.util.Log 7 | import dagger.hilt.android.AndroidEntryPoint 8 | import dev.sasikanth.pinnit.notifications.NotificationRepository 9 | import dev.sasikanth.pinnit.utils.DispatcherProvider 10 | import dev.sasikanth.pinnit.utils.notification.NotificationUtil 11 | import kotlinx.coroutines.CoroutineExceptionHandler 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.SupervisorJob 14 | import kotlinx.coroutines.launch 15 | import java.util.UUID 16 | import javax.inject.Inject 17 | 18 | @AndroidEntryPoint 19 | class DeleteNotificationReceiver : BroadcastReceiver() { 20 | 21 | companion object { 22 | private const val TAG = "DeleteNotificationReceiver" 23 | 24 | const val ACTION_DELETE = "dev.sasikanth.pinnit.system.action.DeleteNotification" 25 | const val EXTRA_NOTIFICATION_UUID = "dev.sasikanth.system.DeleteNotificationReceiver:notificationUuid" 26 | } 27 | 28 | @Inject 29 | lateinit var repository: NotificationRepository 30 | 31 | @Inject 32 | lateinit var notificationUtil: NotificationUtil 33 | 34 | @Inject 35 | lateinit var dispatcherProvider: DispatcherProvider 36 | 37 | private val job = SupervisorJob() 38 | private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> 39 | Log.d(TAG, "Failed to delete the notification.", throwable) 40 | } 41 | private val mainScope by lazy { 42 | CoroutineScope(job + dispatcherProvider.main + coroutineExceptionHandler) 43 | } 44 | 45 | override fun onReceive(context: Context?, intent: Intent?) { 46 | val notificationUuid = intent?.getStringExtra(EXTRA_NOTIFICATION_UUID) 47 | 48 | if (context != null && intent != null && intent.action == ACTION_DELETE && notificationUuid != null) { 49 | val asyncResult = goAsync() 50 | 51 | mainScope.launch { 52 | val notificationId = UUID.fromString(notificationUuid) 53 | val notification = repository.notification(notificationId) 54 | repository.updatePinStatus(notification.uuid, false) 55 | repository.deleteNotification(notification) 56 | 57 | notificationUtil.dismissNotification(notification) 58 | 59 | asyncResult.finish() 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/background/receivers/UnpinNotificationReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.background.receivers 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.util.Log 7 | import dagger.hilt.android.AndroidEntryPoint 8 | import dev.sasikanth.pinnit.notifications.NotificationRepository 9 | import dev.sasikanth.pinnit.utils.DispatcherProvider 10 | import dev.sasikanth.pinnit.utils.notification.NotificationUtil 11 | import kotlinx.coroutines.CoroutineExceptionHandler 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.SupervisorJob 14 | import kotlinx.coroutines.launch 15 | import java.util.UUID 16 | import javax.inject.Inject 17 | 18 | @AndroidEntryPoint 19 | class UnpinNotificationReceiver : BroadcastReceiver() { 20 | 21 | companion object { 22 | private const val TAG = "UnpinNotification" 23 | 24 | const val ACTION_UNPIN = "dev.sasikanth.pinnit.system.action.NotificationUnpin" 25 | const val EXTRA_NOTIFICATION_UUID = "dev.sasikanth.system.UnpinNotificationReceiver:notificationUuid" 26 | } 27 | 28 | @Inject 29 | lateinit var repository: NotificationRepository 30 | 31 | @Inject 32 | lateinit var notificationUtil: NotificationUtil 33 | 34 | @Inject 35 | lateinit var dispatcherProvider: DispatcherProvider 36 | 37 | private val job = SupervisorJob() 38 | private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> 39 | Log.d(TAG, "Failed to unpin the notification.", throwable) 40 | } 41 | private val mainScope by lazy { 42 | CoroutineScope(job + dispatcherProvider.main + coroutineExceptionHandler) 43 | } 44 | 45 | override fun onReceive(context: Context?, intent: Intent?) { 46 | val notificationUuid = intent?.getStringExtra(EXTRA_NOTIFICATION_UUID) 47 | 48 | if (context != null && intent != null && intent.action == ACTION_UNPIN && notificationUuid != null) { 49 | val asyncResult = goAsync() 50 | 51 | mainScope.launch { 52 | val notification = repository.notification(UUID.fromString(notificationUuid)) 53 | repository.updatePinStatus(notification.uuid, false) 54 | 55 | notificationUtil.dismissNotification(notification) 56 | 57 | asyncResult.finish() 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/background/services/NotificationsListener.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.background.services 2 | 3 | import android.service.notification.NotificationListenerService 4 | import android.service.notification.StatusBarNotification 5 | 6 | // TODO: Complete this for v2 7 | class NotificationsListener : NotificationListenerService() { 8 | 9 | override fun onNotificationPosted(sbn: StatusBarNotification?) { 10 | if (sbn == null || sbn.packageName == packageName) return 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/data/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import dev.sasikanth.pinnit.utils.room.InstantRoomTypeConverter 7 | import dev.sasikanth.pinnit.utils.room.LocalDateRoomTypeConverter 8 | import dev.sasikanth.pinnit.utils.room.LocalTimeRoomTypeConverter 9 | import dev.sasikanth.pinnit.utils.room.UuidRoomTypeConverter 10 | 11 | @Database( 12 | entities = [PinnitNotification::class], 13 | version = 2 14 | ) 15 | @TypeConverters( 16 | UuidRoomTypeConverter::class, 17 | InstantRoomTypeConverter::class, 18 | ScheduleTypeConverter::class, 19 | LocalDateRoomTypeConverter::class, 20 | LocalTimeRoomTypeConverter::class 21 | ) 22 | abstract class AppDatabase : RoomDatabase() { 23 | abstract fun notificationDao(): PinnitNotification.RoomDao 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/data/PinnitNotification.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data 2 | 3 | import android.os.Parcelable 4 | import androidx.annotation.Keep 5 | import androidx.room.Dao 6 | import androidx.room.Embedded 7 | import androidx.room.Entity 8 | import androidx.room.Insert 9 | import androidx.room.OnConflictStrategy 10 | import androidx.room.PrimaryKey 11 | import androidx.room.Query 12 | import dev.zacsweers.redacted.annotations.Redacted 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.parcelize.Parcelize 15 | import java.time.Instant 16 | import java.util.UUID 17 | 18 | @Keep 19 | @Entity 20 | @Parcelize 21 | data class PinnitNotification( 22 | @PrimaryKey val uuid: UUID, 23 | @Redacted val title: String, 24 | @Redacted val content: String? = null, 25 | val isPinned: Boolean = true, 26 | val createdAt: Instant, 27 | val updatedAt: Instant, 28 | val deletedAt: Instant? = null, 29 | @Embedded 30 | val schedule: Schedule? = null 31 | ) : Parcelable { 32 | 33 | val hasSchedule: Boolean 34 | get() = schedule != null 35 | 36 | fun equalsTitleAndContent(otherTitle: String?, otherContent: String?) = 37 | title == otherTitle.orEmpty() && content.orEmpty() == otherContent.orEmpty() 38 | 39 | @Dao 40 | interface RoomDao { 41 | 42 | @Insert(onConflict = OnConflictStrategy.REPLACE) 43 | suspend fun save(notifications: List) 44 | 45 | @Query("UPDATE PinnitNotification SET isPinned = :isPinned WHERE uuid = :uuid") 46 | suspend fun updatePinStatus(uuid: UUID, isPinned: Boolean) 47 | 48 | @Query( 49 | """ 50 | SELECT * 51 | FROM PinnitNotification 52 | WHERE deletedAt IS NULL 53 | ORDER BY isPinned DESC, updatedAt DESC 54 | """ 55 | ) 56 | fun notifications(): Flow> 57 | 58 | @Query( 59 | """ 60 | SELECT * 61 | FROM PinnitNotification 62 | WHERE deletedAt IS NULL AND isPinned = 1 63 | ORDER BY updatedAt DESC 64 | """ 65 | ) 66 | suspend fun pinnedNotifications(): List 67 | 68 | @Query("SELECT * FROM PinnitNotification WHERE uuid = :uuid LIMIT 1") 69 | suspend fun notification(uuid: UUID): PinnitNotification 70 | 71 | @Query("UPDATE PinnitNotification SET scheduleDate = null, scheduleTime = null, scheduleType = null WHERE uuid = :notificationId") 72 | suspend fun removeSchedule(notificationId: UUID) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/data/Schedule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data 2 | 3 | import android.os.Parcelable 4 | import androidx.room.TypeConverter 5 | import dev.sasikanth.pinnit.utils.UserClock 6 | import kotlinx.parcelize.Parcelize 7 | import java.time.Duration 8 | import java.time.LocalDate 9 | import java.time.LocalDateTime 10 | import java.time.LocalTime 11 | 12 | @Parcelize 13 | data class Schedule( 14 | val scheduleDate: LocalDate?, 15 | val scheduleTime: LocalTime?, 16 | val scheduleType: ScheduleType? 17 | ) : Parcelable { 18 | 19 | fun removeScheduleRepeat(): Schedule { 20 | return copy(scheduleType = null) 21 | } 22 | 23 | fun addScheduleRepeat(): Schedule { 24 | return copy(scheduleType = ScheduleType.Daily) 25 | } 26 | 27 | fun scheduleDateChanged(date: LocalDate): Schedule { 28 | return copy(scheduleDate = date) 29 | } 30 | 31 | fun scheduleTimeChanged(time: LocalTime?): Schedule { 32 | return copy(scheduleTime = time) 33 | } 34 | 35 | fun scheduleTypeChanged(scheduleType: ScheduleType): Schedule { 36 | return copy(scheduleType = scheduleType) 37 | } 38 | 39 | companion object { 40 | 41 | fun default(userClock: UserClock): Schedule { 42 | val userDateTime = LocalDateTime.now(userClock) 43 | .plus(Duration.ofMinutes(10)) 44 | 45 | return Schedule( 46 | scheduleDate = userDateTime.toLocalDate(), 47 | scheduleTime = userDateTime.toLocalTime(), 48 | scheduleType = ScheduleType.Daily 49 | ) 50 | } 51 | } 52 | } 53 | 54 | enum class ScheduleType { 55 | Daily, Weekly, Monthly 56 | } 57 | 58 | class ScheduleTypeConverter { 59 | 60 | @TypeConverter 61 | fun toScheduleType(value: String?): ScheduleType? { 62 | return if (value != null) { 63 | enumValueOf(value) 64 | } else { 65 | null 66 | } 67 | } 68 | 69 | @TypeConverter 70 | fun fromScheduleType(scheduleType: ScheduleType?): String? { 71 | return scheduleType?.toString() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/data/migrations/Migration_1_2.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data.migrations 2 | 3 | import androidx.room.migration.Migration 4 | import androidx.sqlite.db.SupportSQLiteDatabase 5 | import dev.sasikanth.pinnit.utils.inTransaction 6 | 7 | @Suppress("ClassName") 8 | object Migration_1_2 : Migration(1, 2) { 9 | 10 | override fun migrate(db: SupportSQLiteDatabase) { 11 | with(db) { 12 | inTransaction { 13 | execSQL(""" ALTER TABLE PinnitNotification ADD COLUMN scheduleDate TEXT DEFAULT NULL """) 14 | execSQL(""" ALTER TABLE PinnitNotification ADD COLUMN scheduleTime TEXT DEFAULT NULL """) 15 | execSQL(""" ALTER TABLE PinnitNotification ADD COLUMN scheduleType TEXT DEFAULT NULL """) 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/data/preferences/AppPreferencesDataStore.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data.preferences 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.dataStore 6 | import androidx.datastore.migrations.SharedPreferencesMigration 7 | import androidx.datastore.migrations.SharedPreferencesView 8 | import dev.sasikanth.pinnit.BuildConfig 9 | import dev.sasikanth.pinnit.R 10 | 11 | private const val PREFERENCES_NAME = "${BuildConfig.APPLICATION_ID}_preferences" 12 | private const val DATA_STORE_FILE_NAME = "app_prefs.pb" 13 | private const val KEY_THEME = "pref_theme" 14 | private const val KEY_OEM_WARNING_DIALOG = "pref_oem_warning_dialog" 15 | 16 | val Context.appPreferencesStore: DataStore by dataStore( 17 | fileName = DATA_STORE_FILE_NAME, 18 | serializer = AppPreferencesSerializer, 19 | produceMigrations = { context -> 20 | listOf(SharedPreferencesMigration( 21 | context = context, 22 | sharedPreferencesName = PREFERENCES_NAME, 23 | keysToMigrate = setOf(KEY_THEME, KEY_OEM_WARNING_DIALOG) 24 | ) { sharedPrefs: SharedPreferencesView, currentData: AppPreferences -> 25 | var newCurrentData = currentData 26 | 27 | if (newCurrentData.theme == AppPreferences.Theme.UNSPECIFIED) { 28 | newCurrentData = currentData.toBuilder() 29 | .setTheme(getThemeForStorageValue(context, sharedPrefs)) 30 | .build() 31 | } 32 | 33 | val preferencesOemWarningDialog = sharedPrefs.getBoolean(KEY_OEM_WARNING_DIALOG, false) 34 | if (newCurrentData.oemWarningDialog != preferencesOemWarningDialog) { 35 | newCurrentData = newCurrentData.toBuilder() 36 | .setOemWarningDialog(preferencesOemWarningDialog) 37 | .build() 38 | } 39 | 40 | newCurrentData 41 | }) 42 | } 43 | ) 44 | 45 | private fun getThemeForStorageValue(context: Context, sharedPrefs: SharedPreferencesView): AppPreferences.Theme { 46 | val defaultThemeValue = context.getString(R.string.pref_theme_auto) 47 | 48 | return when (sharedPrefs.getString(KEY_THEME, defaultThemeValue)) { 49 | context.getString(R.string.pref_theme_dark) -> AppPreferences.Theme.DARK 50 | context.getString(R.string.pref_theme_light) -> AppPreferences.Theme.LIGHT 51 | else -> AppPreferences.Theme.AUTO 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/data/preferences/AppPreferencesSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.data.preferences 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import com.google.protobuf.InvalidProtocolBufferException 6 | import java.io.InputStream 7 | import java.io.OutputStream 8 | 9 | object AppPreferencesSerializer : Serializer { 10 | 11 | override val defaultValue: AppPreferences = AppPreferences.getDefaultInstance() 12 | 13 | override suspend fun readFrom(input: InputStream): AppPreferences { 14 | try { 15 | return AppPreferences.parseFrom(input) 16 | } catch (exception: InvalidProtocolBufferException) { 17 | throw CorruptionException("Cannot read proto.", exception) 18 | } 19 | } 20 | 21 | override suspend fun writeTo(t: AppPreferences, output: OutputStream) = t.writeTo(output) 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.spotify.mobius.android.runners.MainThreadWorkRunner 6 | import com.spotify.mobius.runners.WorkRunner 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import dev.sasikanth.pinnit.data.AppDatabase 13 | import dev.sasikanth.pinnit.data.migrations.Migration_1_2 14 | import dev.sasikanth.pinnit.utils.CoroutineDispatcherProvider 15 | import dev.sasikanth.pinnit.utils.DispatcherProvider 16 | import dev.sasikanth.pinnit.utils.RealUserClock 17 | import dev.sasikanth.pinnit.utils.UserClock 18 | import dev.sasikanth.pinnit.utils.UtcClock 19 | import java.time.ZoneId 20 | import javax.inject.Singleton 21 | 22 | @InstallIn(SingletonComponent::class) 23 | @Module 24 | object AppModule { 25 | 26 | @Singleton 27 | @Provides 28 | fun providesAppDatabase( 29 | @ApplicationContext context: Context 30 | ): AppDatabase { 31 | return Room.databaseBuilder(context, AppDatabase::class.java, "pinnit-db") 32 | .addMigrations(Migration_1_2) 33 | .build() 34 | } 35 | 36 | @Singleton 37 | @Provides 38 | fun providesUtcClock(): UtcClock = UtcClock() 39 | 40 | @Singleton 41 | @Provides 42 | fun providesUserClock(userTimeZone: ZoneId): UserClock = RealUserClock(userTimeZone) 43 | 44 | @Singleton 45 | @Provides 46 | fun providesDispatcherProvider(): DispatcherProvider = CoroutineDispatcherProvider() 47 | 48 | @Singleton 49 | @Provides 50 | fun providesSystemDefaultZone(): ZoneId = ZoneId.systemDefault() 51 | 52 | @Singleton 53 | @Provides 54 | fun providesWorkRunner(): WorkRunner = MainThreadWorkRunner.create() 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/di/DateTimeFormat.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Qualifier 6 | annotation class DateTimeFormat(val value: Type) { 7 | 8 | enum class Type { 9 | ScheduleDateFormat, 10 | ScheduleTimeFormat 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/di/DateTimeFormatterModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.di 2 | 3 | import android.content.Context 4 | import android.text.format.DateFormat 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import dev.sasikanth.pinnit.di.DateTimeFormat.Type.ScheduleDateFormat 11 | import dev.sasikanth.pinnit.di.DateTimeFormat.Type.ScheduleTimeFormat 12 | import java.time.format.DateTimeFormatter 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object DateTimeFormatterModule { 17 | 18 | @Provides 19 | @DateTimeFormat(ScheduleDateFormat) 20 | fun providesScheduleDateFormat(): DateTimeFormatter { 21 | return DateTimeFormatter.ofPattern("MMM d") 22 | } 23 | 24 | @Provides 25 | @DateTimeFormat(ScheduleTimeFormat) 26 | fun providesScheduleTimeFormat( 27 | isClock24HourFormat: Boolean 28 | ): DateTimeFormatter { 29 | return if (isClock24HourFormat) { 30 | DateTimeFormatter.ofPattern("HH:mm") 31 | } else { 32 | DateTimeFormatter.ofPattern("h:mm a") 33 | } 34 | } 35 | 36 | @Provides 37 | fun providesIsClock24HourFormat( 38 | @ApplicationContext context: Context 39 | ): Boolean { 40 | return DateFormat.is24HourFormat(context) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/di/PreferencesModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import dev.sasikanth.pinnit.data.preferences.AppPreferences 11 | import dev.sasikanth.pinnit.data.preferences.appPreferencesStore 12 | import javax.inject.Singleton 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object PreferencesModule { 17 | 18 | @Singleton 19 | @Provides 20 | fun providesAppPreferencesStore( 21 | @ApplicationContext context: Context 22 | ): DataStore { 23 | return context.appPreferencesStore 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/di/WorkManagerModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.di 2 | 3 | import android.content.Context 4 | import androidx.hilt.work.HiltWorkerFactory 5 | import androidx.work.Configuration 6 | import androidx.work.WorkManager 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object WorkManagerModule { 17 | 18 | @Singleton 19 | @Provides 20 | fun providesWorkManager( 21 | @ApplicationContext context: Context 22 | ): WorkManager = WorkManager.getInstance(context) 23 | 24 | @Singleton 25 | @Provides 26 | fun providesWorkManagerConfiguration( 27 | workerFactory: HiltWorkerFactory 28 | ): Configuration { 29 | return Configuration.Builder() 30 | .setWorkerFactory(workerFactory) 31 | .build() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/CurrentDateValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import com.google.android.material.datepicker.CalendarConstraints 6 | import dev.sasikanth.pinnit.utils.RealUserClock 7 | import dev.sasikanth.pinnit.utils.UserClock 8 | import dev.sasikanth.pinnit.utils.UtcClock 9 | import java.time.Instant 10 | import java.time.LocalDate 11 | import java.time.ZoneId 12 | import javax.inject.Inject 13 | 14 | /** 15 | * Only enables date selection in [com.google.android.material.datepicker.MaterialDatePicker] 16 | * when the date is greater or equal to the current date. 17 | */ 18 | class CurrentDateValidator @Inject constructor( 19 | userClock: UserClock, 20 | private val utcClock: UtcClock 21 | ) : CalendarConstraints.DateValidator { 22 | 23 | private val currentDate = LocalDate.now(userClock) 24 | 25 | override fun describeContents(): Int { 26 | return 0 27 | } 28 | 29 | override fun writeToParcel(parcel: Parcel, flags: Int) { 30 | parcel.writeInt(flags) 31 | } 32 | 33 | override fun isValid(date: Long): Boolean { 34 | val instant = Instant.ofEpochMilli(date) 35 | val localDate = instant.atZone(utcClock.zone).toLocalDate() 36 | 37 | return localDate >= currentDate 38 | } 39 | 40 | companion object CREATOR : Parcelable.Creator { 41 | 42 | override fun createFromParcel(parcel: Parcel): CurrentDateValidator { 43 | return CurrentDateValidator( 44 | userClock = RealUserClock(ZoneId.systemDefault()), 45 | utcClock = UtcClock() 46 | ) 47 | } 48 | 49 | override fun newArray(size: Int): Array { 50 | return arrayOfNulls(size) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | import dev.sasikanth.pinnit.data.Schedule 5 | import java.time.LocalDate 6 | import java.time.LocalTime 7 | import java.util.UUID 8 | 9 | sealed class EditorScreenEffect 10 | 11 | data class LoadNotification(val uuid: UUID) : EditorScreenEffect() 12 | 13 | data class SetTitleAndContent(val title: String?, val content: String?) : EditorScreenEffect() 14 | 15 | data class SaveNotification( 16 | val title: String, 17 | val content: String?, 18 | val schedule: Schedule?, 19 | val canPinNotification: Boolean 20 | ) : EditorScreenEffect() 21 | 22 | data class UpdateNotification( 23 | val notificationUuid: UUID, 24 | val title: String, 25 | val content: String?, 26 | val schedule: Schedule? 27 | ) : EditorScreenEffect() 28 | 29 | data object CloseEditor : EditorScreenEffect() 30 | 31 | data object ShowConfirmExitEditor : EditorScreenEffect() 32 | 33 | data class DeleteNotification(val notification: PinnitNotification) : EditorScreenEffect() 34 | 35 | data object ShowConfirmDelete : EditorScreenEffect() 36 | 37 | data class ShowDatePicker(val date: LocalDate) : EditorScreenEffect() 38 | 39 | data class ShowTimePicker(var time: LocalTime) : EditorScreenEffect() 40 | 41 | data class ShowNotification(val notification: PinnitNotification) : EditorScreenEffect() 42 | 43 | data class ScheduleNotification(val notification: PinnitNotification) : EditorScreenEffect() 44 | 45 | data class CancelNotificationSchedule(val notificationId: UUID) : EditorScreenEffect() 46 | 47 | data class ValidateSchedule(val scheduleDate: LocalDate, val scheduleTime: LocalTime) : EditorScreenEffect() 48 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenEvent.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | import dev.sasikanth.pinnit.data.Schedule 5 | import dev.sasikanth.pinnit.data.ScheduleType 6 | import java.time.LocalDate 7 | import java.time.LocalTime 8 | 9 | sealed class EditorScreenEvent 10 | 11 | data class NotificationLoaded(val notification: PinnitNotification) : EditorScreenEvent() 12 | 13 | data class TitleChanged(val title: String) : EditorScreenEvent() 14 | 15 | data class ContentChanged(val content: String?) : EditorScreenEvent() 16 | 17 | data object SaveClicked : EditorScreenEvent() 18 | 19 | data object BackClicked : EditorScreenEvent() 20 | 21 | data object ConfirmedExit : EditorScreenEvent() 22 | 23 | data object DeleteNotificationClicked : EditorScreenEvent() 24 | 25 | data object ConfirmDeleteNotification : EditorScreenEvent() 26 | 27 | data class AddScheduleClicked(val schedule: Schedule) : EditorScreenEvent() 28 | 29 | data object RemoveScheduleClicked : EditorScreenEvent() 30 | 31 | data object ScheduleRepeatUnchecked : EditorScreenEvent() 32 | 33 | data object ScheduleRepeatChecked : EditorScreenEvent() 34 | 35 | data object ScheduleDateClicked : EditorScreenEvent() 36 | 37 | data object ScheduleTimeClicked : EditorScreenEvent() 38 | 39 | data class ScheduleDateChanged(val date: LocalDate) : EditorScreenEvent() 40 | 41 | data class ScheduleTimeChanged(val time: LocalTime) : EditorScreenEvent() 42 | 43 | data class ScheduleTypeChanged(val scheduleType: ScheduleType) : EditorScreenEvent() 44 | 45 | data class NotificationSaved(val notification: PinnitNotification) : EditorScreenEvent() 46 | 47 | data class NotificationUpdated(val updatedNotification: PinnitNotification) : EditorScreenEvent() 48 | 49 | data class ScheduleValidated(val result: ScheduleValidator.Result) : EditorScreenEvent() 50 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenInit.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import com.spotify.mobius.First 4 | import com.spotify.mobius.First.first 5 | import com.spotify.mobius.Init 6 | import javax.inject.Inject 7 | 8 | class EditorScreenInit @Inject constructor() : Init { 9 | 10 | override fun init(model: EditorScreenModel): First { 11 | if (model.notificationUuid != null) { 12 | // We are only loading notification if it's not already been loaded 13 | if (model.notification == null) { 14 | return first(model, setOf(LoadNotification(model.notificationUuid))) 15 | } 16 | } else { 17 | return first(model, setOf(SetTitleAndContent(model.title, model.content))) 18 | } 19 | 20 | return first(model) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenUi.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import dev.sasikanth.pinnit.data.ScheduleType 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | 7 | interface EditorScreenUi { 8 | fun enableSave() 9 | fun disableSave() 10 | fun renderSaveActionButtonText() 11 | fun renderSaveAndPinActionButtonText() 12 | fun showDeleteButton() 13 | fun hideDeleteButton() 14 | fun showScheduleView() 15 | fun renderScheduleDateTime(scheduleDate: LocalDate, scheduleTime: LocalTime) 16 | fun renderScheduleRepeat(scheduleType: ScheduleType?, hasValidScheduleResult: Boolean) 17 | fun hideScheduleView() 18 | fun showScheduleWarning() 19 | fun hideScheduleWarning() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenUiRender.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | class EditorScreenUiRender(private val ui: EditorScreenUi) { 4 | 5 | fun render(model: EditorScreenModel) { 6 | if (model.isNotificationLoaded) { 7 | ui.renderSaveActionButtonText() 8 | ui.showDeleteButton() 9 | } else { 10 | renderSaveButton(model) 11 | ui.hideDeleteButton() 12 | } 13 | 14 | if (model.hasNotificationTitle && model.hasValidScheduleResult && 15 | (model.hasTitleAndContentChanged || model.hasScheduleChanged) 16 | ) { 17 | ui.enableSave() 18 | } else { 19 | ui.disableSave() 20 | } 21 | 22 | renderScheduleView(model) 23 | } 24 | 25 | private fun renderSaveButton(model: EditorScreenModel) { 26 | if (model.hasSchedule) { 27 | ui.renderSaveActionButtonText() 28 | } else { 29 | ui.renderSaveAndPinActionButtonText() 30 | } 31 | } 32 | 33 | private fun renderScheduleView(model: EditorScreenModel) { 34 | if (model.hasSchedule) { 35 | val schedule = model.schedule!! 36 | 37 | ui.showScheduleView() 38 | ui.renderScheduleDateTime(scheduleDate = schedule.scheduleDate!!, scheduleTime = schedule.scheduleTime!!) 39 | ui.renderScheduleRepeat(scheduleType = schedule.scheduleType, hasValidScheduleResult = model.hasValidScheduleResult) 40 | 41 | if (!model.hasValidScheduleResult) { 42 | ui.showScheduleWarning() 43 | } else { 44 | ui.hideScheduleWarning() 45 | } 46 | } else { 47 | ui.hideScheduleView() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenViewEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import java.time.LocalDate 4 | import java.time.LocalTime 5 | 6 | sealed class EditorScreenViewEffect 7 | 8 | data object CloseEditorView : EditorScreenViewEffect() 9 | 10 | data class SetTitle(val title: String?) : EditorScreenViewEffect() 11 | 12 | data class SetContent(val content: String?) : EditorScreenViewEffect() 13 | 14 | data object ShowConfirmExitEditorDialog : EditorScreenViewEffect() 15 | 16 | data object ShowConfirmDeleteDialog : EditorScreenViewEffect() 17 | 18 | data class ShowDatePickerDialog(val date: LocalDate) : EditorScreenViewEffect() 19 | 20 | data class ShowTimePickerDialog(val time: LocalTime) : EditorScreenViewEffect() 21 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import com.spotify.mobius.Mobius 5 | import com.spotify.mobius.android.MobiusLoopViewModel 6 | import com.spotify.mobius.runners.WorkRunner 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import dev.sasikanth.pinnit.utils.MAX_VIEW_EFFECTS_QUEUE_SIZE 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class EditorScreenViewModel @Inject constructor( 13 | private val savedStateHandle: SavedStateHandle, 14 | update: EditorScreenUpdate, 15 | init: EditorScreenInit, 16 | effectHandlerFactory: EditorScreenEffectHandler.Factory, 17 | workRunner: WorkRunner 18 | ) : MobiusLoopViewModel( 19 | { viewEffectsConsumer -> 20 | Mobius.loop(update, effectHandlerFactory.create(viewEffectsConsumer).build()) 21 | }, 22 | editorScreenModel(savedStateHandle), 23 | init, 24 | workRunner, 25 | MAX_VIEW_EFFECTS_QUEUE_SIZE 26 | ) { 27 | 28 | companion object { 29 | private const val MODEL_KEY = "EDITOR_SCREEN_MODEL" 30 | 31 | private fun editorScreenModel(savedStateHandle: SavedStateHandle): EditorScreenModel { 32 | val args = EditorScreenArgs.fromSavedStateHandle(savedStateHandle) 33 | 34 | return savedStateHandle.get(MODEL_KEY) ?: EditorScreenModel.default( 35 | notificationUuid = args.notificationUuid, 36 | title = args.notificationTitle, 37 | content = args.notificationContent 38 | ) 39 | } 40 | } 41 | 42 | override fun onClearedInternal() { 43 | savedStateHandle[MODEL_KEY] = model 44 | super.onClearedInternal() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/EditorTransition.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import android.os.Parcelable 4 | import androidx.annotation.Keep 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Keep 8 | sealed class EditorTransition : Parcelable { 9 | 10 | @Parcelize 11 | data object SharedAxis : EditorTransition() 12 | 13 | @Parcelize 14 | data class ContainerTransform(val transitionName: String) : EditorTransition() 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/editor/ScheduleValidator.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import android.os.Parcelable 4 | import dev.sasikanth.pinnit.editor.ScheduleValidator.Result.ScheduleInPastError 5 | import dev.sasikanth.pinnit.editor.ScheduleValidator.Result.Valid 6 | import dev.sasikanth.pinnit.utils.UserClock 7 | import kotlinx.parcelize.Parcelize 8 | import java.time.LocalDate 9 | import java.time.LocalDateTime 10 | import java.time.LocalTime 11 | import javax.inject.Inject 12 | 13 | class ScheduleValidator @Inject constructor(val userClock: UserClock) { 14 | 15 | fun validate(scheduleDate: LocalDate, scheduleTime: LocalTime): Result { 16 | val scheduleDateTime = scheduleDate.atTime(scheduleTime) 17 | val now = LocalDateTime.now(userClock) 18 | 19 | if (scheduleDateTime.isBefore(now)) { 20 | return ScheduleInPastError 21 | } 22 | 23 | return Valid 24 | } 25 | 26 | sealed class Result : Parcelable { 27 | 28 | @Parcelize 29 | data object Valid : Result() 30 | 31 | @Parcelize 32 | data object ScheduleInPastError : Result() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dev.sasikanth.pinnit.data.AppDatabase 8 | import dev.sasikanth.pinnit.data.PinnitNotification 9 | import javax.inject.Singleton 10 | 11 | @InstallIn(SingletonComponent::class) 12 | @Module 13 | object NotificationModule { 14 | 15 | @Singleton 16 | @Provides 17 | fun providesNotificationDao(appDatabase: AppDatabase): PinnitNotification.RoomDao { 18 | return appDatabase.notificationDao() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | import dev.sasikanth.pinnit.data.Schedule 5 | import dev.sasikanth.pinnit.utils.UtcClock 6 | import kotlinx.coroutines.flow.Flow 7 | import java.time.Instant 8 | import java.util.UUID 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class NotificationRepository @Inject constructor( 14 | private val notificationDao: PinnitNotification.RoomDao, 15 | private val utcClock: UtcClock 16 | ) { 17 | 18 | suspend fun save( 19 | title: String, 20 | content: String? = null, 21 | isPinned: Boolean = true, 22 | schedule: Schedule? = null, 23 | uuid: UUID = UUID.randomUUID() 24 | ): PinnitNotification { 25 | val notification = PinnitNotification( 26 | uuid = uuid, 27 | title = title, 28 | content = content, 29 | isPinned = isPinned, 30 | schedule = schedule, 31 | createdAt = Instant.now(utcClock), 32 | updatedAt = Instant.now(utcClock), 33 | deletedAt = null 34 | ) 35 | notificationDao.save(listOf(notification)) 36 | return notification 37 | } 38 | 39 | suspend fun save(notifications: List) { 40 | notificationDao.save(notifications) 41 | } 42 | 43 | suspend fun updateNotification(notification: PinnitNotification): PinnitNotification { 44 | val updatedNotification = notification.copy( 45 | updatedAt = Instant.now(utcClock) 46 | ) 47 | notificationDao.save(listOf(updatedNotification)) 48 | return updatedNotification 49 | } 50 | 51 | suspend fun notification(uuid: UUID): PinnitNotification { 52 | return notificationDao.notification(uuid) 53 | } 54 | 55 | suspend fun updatePinStatus(notificationUuid: UUID, isPinned: Boolean) { 56 | notificationDao.updatePinStatus(notificationUuid, isPinned) 57 | } 58 | 59 | fun notifications(): Flow> { 60 | return notificationDao.notifications() 61 | } 62 | 63 | suspend fun pinnedNotifications(): List { 64 | return notificationDao.pinnedNotifications() 65 | } 66 | 67 | suspend fun deleteNotification(notification: PinnitNotification): PinnitNotification { 68 | val deletedNotification = notification.copy( 69 | deletedAt = Instant.now(utcClock) 70 | ) 71 | notificationDao.save(listOf(deletedNotification)) 72 | return deletedNotification 73 | } 74 | 75 | suspend fun undoNotificationDelete(notification: PinnitNotification) { 76 | val undidNotification = notification.copy( 77 | deletedAt = null 78 | ) 79 | notificationDao.save(listOf(undidNotification)) 80 | } 81 | 82 | suspend fun removeSchedule(notificationId: UUID) { 83 | notificationDao.removeSchedule(notificationId) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationScreenViewEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import java.util.UUID 4 | 5 | sealed class NotificationScreenViewEffect 6 | 7 | data class UndoNotificationDeleteViewEffect(val notificationUuid: UUID) : NotificationScreenViewEffect() 8 | 9 | data object RequestNotificationPermissionViewEffect : NotificationScreenViewEffect() 10 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | import java.util.UUID 5 | 6 | sealed class NotificationsScreenEffect 7 | 8 | data object LoadNotifications : NotificationsScreenEffect() 9 | 10 | data class ToggleNotificationPinStatus(val notification: PinnitNotification) : NotificationsScreenEffect() 11 | 12 | data class DeleteNotification(val notification: PinnitNotification) : NotificationsScreenEffect() 13 | 14 | data class UndoDeletedNotification(val notificationUuid: UUID) : NotificationsScreenEffect() 15 | 16 | data object CheckNotificationsVisibility : NotificationsScreenEffect() 17 | 18 | data class ShowUndoDeleteNotification(val notification: PinnitNotification) : NotificationsScreenEffect() 19 | 20 | data class CancelNotificationSchedule(val notificationId: UUID) : NotificationsScreenEffect() 21 | 22 | data class RemoveSchedule(val notificationId: UUID) : NotificationsScreenEffect() 23 | 24 | data class ScheduleNotification(val notification: PinnitNotification) : NotificationsScreenEffect() 25 | 26 | data object CheckPermissionToPostNotification : NotificationsScreenEffect() 27 | 28 | data object RequestNotificationPermission : NotificationsScreenEffect() 29 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEvent.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | import java.util.UUID 5 | 6 | sealed class NotificationsScreenEvent 7 | 8 | data class NotificationsLoaded(val notifications: List) : NotificationsScreenEvent() 9 | 10 | data class NotificationSwiped(val notification: PinnitNotification) : NotificationsScreenEvent() 11 | 12 | data class TogglePinStatusClicked(val notification: PinnitNotification) : NotificationsScreenEvent() 13 | 14 | data class UndoNotificationDelete(val notificationUuid: UUID) : NotificationsScreenEvent() 15 | 16 | data class NotificationDeleted(val notification: PinnitNotification) : NotificationsScreenEvent() 17 | 18 | data class RemovedNotificationSchedule(val notificationId: UUID) : NotificationsScreenEvent() 19 | 20 | data class RemoveNotificationScheduleClicked(val notificationId: UUID) : NotificationsScreenEvent() 21 | 22 | data class RestoredDeletedNotification(val notification: PinnitNotification) : NotificationsScreenEvent() 23 | 24 | data class HasPermissionToPostNotifications(val canPostNotifications: Boolean) : NotificationsScreenEvent() 25 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInit.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import com.spotify.mobius.First 4 | import com.spotify.mobius.First.first 5 | import com.spotify.mobius.Init 6 | import javax.inject.Inject 7 | 8 | class NotificationsScreenInit @Inject constructor() : Init { 9 | override fun init(model: NotificationsScreenModel): First { 10 | val effects = mutableSetOf(CheckPermissionToPostNotification) 11 | 12 | if (model.notificationsQueried.not()) { 13 | // We are only checking for notifications visibility during 14 | // screen create because system notifications are disappear only 15 | // if the app is force closed (or when updating). So app needs 16 | // to be reopened again. Notifications will persist 17 | // orientation changes, so no point checking again. 18 | effects.addAll(listOf(LoadNotifications, CheckNotificationsVisibility)) 19 | } 20 | 21 | return first(model, effects) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import android.os.Parcelable 4 | import dev.sasikanth.pinnit.data.PinnitNotification 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class NotificationsScreenModel( 9 | val notifications: List? 10 | ) : Parcelable { 11 | 12 | companion object { 13 | fun default() = NotificationsScreenModel(notifications = null) 14 | } 15 | 16 | val notificationsQueried: Boolean 17 | get() = !notifications.isNullOrEmpty() 18 | 19 | fun onNotificationsLoaded(notifications: List) = copy(notifications = notifications) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUiRender.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | 5 | interface NotificationsScreenUi { 6 | fun showNotifications(notifications: List) 7 | fun showNotificationsEmptyError() 8 | fun hideNotificationsEmptyError() 9 | fun hideNotifications() 10 | } 11 | 12 | class NotificationsScreenUiRender( 13 | private val ui: NotificationsScreenUi 14 | ) { 15 | 16 | fun render(model: NotificationsScreenModel) { 17 | if (model.notifications == null) { 18 | return 19 | } 20 | 21 | if (model.notifications.isNotEmpty()) { 22 | ui.hideNotificationsEmptyError() 23 | ui.showNotifications(model.notifications) 24 | } else { 25 | ui.hideNotifications() 26 | ui.showNotificationsEmptyError() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdate.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import com.spotify.mobius.Next 4 | import com.spotify.mobius.Next.dispatch 5 | import com.spotify.mobius.Next.next 6 | import com.spotify.mobius.Next.noChange 7 | import com.spotify.mobius.Update 8 | import javax.inject.Inject 9 | 10 | class NotificationsScreenUpdate @Inject constructor() : Update { 11 | override fun update(model: NotificationsScreenModel, event: NotificationsScreenEvent): Next { 12 | return when (event) { 13 | is NotificationsLoaded -> next(model.onNotificationsLoaded(event.notifications)) 14 | is NotificationSwiped -> dispatch(setOf(DeleteNotification(event.notification))) 15 | is TogglePinStatusClicked -> dispatch(setOf(ToggleNotificationPinStatus(event.notification))) 16 | is UndoNotificationDelete -> dispatch(setOf(UndoDeletedNotification(event.notificationUuid))) 17 | is NotificationDeleted -> notificationDeleted(event) 18 | is RemovedNotificationSchedule -> dispatch(setOf(CancelNotificationSchedule(event.notificationId))) 19 | is RemoveNotificationScheduleClicked -> dispatch(setOf(RemoveSchedule(event.notificationId))) 20 | is RestoredDeletedNotification -> dispatch(setOf(ScheduleNotification(event.notification))) 21 | is HasPermissionToPostNotifications -> hasPermissionToPostNotifications(event.canPostNotifications) 22 | } 23 | } 24 | 25 | private fun hasPermissionToPostNotifications(canPostNotifications: Boolean): Next { 26 | return if (!canPostNotifications) { 27 | dispatch(setOf(RequestNotificationPermission)) 28 | } else { 29 | noChange() 30 | } 31 | } 32 | 33 | private fun notificationDeleted(event: NotificationDeleted): Next { 34 | val notification = event.notification 35 | val effects = mutableSetOf(ShowUndoDeleteNotification(event.notification)) 36 | 37 | if (notification.hasSchedule) { 38 | effects.add(CancelNotificationSchedule(notification.uuid)) 39 | } 40 | 41 | return dispatch(effects) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import com.spotify.mobius.Mobius 5 | import com.spotify.mobius.android.MobiusLoopViewModel 6 | import com.spotify.mobius.runners.WorkRunner 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import dev.sasikanth.pinnit.utils.MAX_VIEW_EFFECTS_QUEUE_SIZE 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class NotificationsScreenViewModel @Inject constructor( 13 | private val savedStateHandle: SavedStateHandle, 14 | update: NotificationsScreenUpdate, 15 | init: NotificationsScreenInit, 16 | effectHandlerFactory: NotificationsScreenEffectHandler.Factory, 17 | workRunner: WorkRunner 18 | ) : MobiusLoopViewModel( 19 | { viewEffectsConsumer -> 20 | Mobius.loop(update, effectHandlerFactory.create(viewEffectsConsumer).build()) 21 | }, 22 | savedStateHandle.get(MODEL_KEY) ?: NotificationsScreenModel.default(), 23 | init, 24 | workRunner, 25 | MAX_VIEW_EFFECTS_QUEUE_SIZE 26 | ) { 27 | 28 | companion object { 29 | private const val MODEL_KEY = "NOTIFICATIONS_SCREEN_MODEL" 30 | } 31 | 32 | override fun onClearedInternal() { 33 | savedStateHandle[MODEL_KEY] = model 34 | super.onClearedInternal() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/adapter/NotificationPinItemAnimator.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications.adapter 2 | 3 | import android.view.animation.AccelerateInterpolator 4 | import androidx.interpolator.view.animation.FastOutSlowInInterpolator 5 | import androidx.recyclerview.widget.DefaultItemAnimator 6 | import androidx.recyclerview.widget.RecyclerView 7 | import dev.sasikanth.pinnit.notifications.adapter.NotificationsListAdapter.NotificationViewHolder 8 | import java.util.UUID 9 | 10 | class NotificationPinItemAnimator : DefaultItemAnimator() { 11 | 12 | private val accelerateInterpolator = AccelerateInterpolator() 13 | private val fastOutSlowInInterpolator = FastOutSlowInInterpolator() 14 | 15 | override fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean { 16 | return true 17 | } 18 | 19 | private fun getItemHolderInfo( 20 | viewHolder: RecyclerView.ViewHolder, 21 | info: NotificationItemInfo 22 | ): ItemHolderInfo { 23 | val notification = (viewHolder as NotificationViewHolder).notification 24 | if (notification != null) { 25 | info.uuid = notification.uuid 26 | info.isPinned = notification.isPinned 27 | } 28 | return info 29 | } 30 | 31 | override fun obtainHolderInfo(): ItemHolderInfo { 32 | return NotificationItemInfo() 33 | } 34 | 35 | override fun recordPreLayoutInformation( 36 | state: RecyclerView.State, 37 | viewHolder: RecyclerView.ViewHolder, 38 | changeFlags: Int, 39 | payloads: MutableList 40 | ): ItemHolderInfo { 41 | val notificationItemInfo = super.recordPreLayoutInformation( 42 | state, 43 | viewHolder, 44 | changeFlags, 45 | payloads 46 | ) as NotificationItemInfo 47 | return getItemHolderInfo(viewHolder, notificationItemInfo) 48 | } 49 | 50 | override fun recordPostLayoutInformation( 51 | state: RecyclerView.State, 52 | viewHolder: RecyclerView.ViewHolder 53 | ): ItemHolderInfo { 54 | val notificationItemInfo = super.recordPostLayoutInformation(state, viewHolder) as NotificationItemInfo 55 | return getItemHolderInfo(viewHolder, notificationItemInfo) 56 | } 57 | 58 | override fun animateChange( 59 | oldHolder: RecyclerView.ViewHolder, 60 | newHolder: RecyclerView.ViewHolder, 61 | preInfo: ItemHolderInfo, 62 | postInfo: ItemHolderInfo 63 | ): Boolean { 64 | if (newHolder != oldHolder) { 65 | return super.animateChange(oldHolder, newHolder, preInfo, postInfo) 66 | } 67 | 68 | if ((newHolder as? NotificationViewHolder != null) && 69 | (oldHolder as? NotificationViewHolder != null) && 70 | (preInfo as? NotificationItemInfo != null) && 71 | (postInfo as? NotificationItemInfo != null) 72 | ) { 73 | val oldPinStatus = preInfo.isPinned 74 | val newPinStatus = postInfo.isPinned 75 | 76 | if (oldPinStatus == newPinStatus || preInfo.uuid != postInfo.uuid) { 77 | return super.animateChange(oldHolder, newHolder, preInfo, postInfo) 78 | } 79 | 80 | return try { 81 | newHolder.animateReveal( 82 | newPinStatus = newPinStatus, 83 | fastOutSlowInInterpolator = fastOutSlowInInterpolator, 84 | accelerateInterpolator = accelerateInterpolator, 85 | dispatchAnimationFinished = { 86 | dispatchAnimationFinished(newHolder) 87 | } 88 | ) 89 | true 90 | } catch (e: Exception) { 91 | super.animateChange(oldHolder, newHolder, preInfo, postInfo) 92 | } 93 | } else { 94 | return super.animateChange(oldHolder, newHolder, preInfo, postInfo) 95 | } 96 | } 97 | 98 | data class NotificationItemInfo( 99 | var uuid: UUID? = null, 100 | var isPinned: Boolean = false 101 | ) : ItemHolderInfo() 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/adapter/NotificationsDiffCallback.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications.adapter 2 | 3 | import androidx.recyclerview.widget.DiffUtil 4 | import dev.sasikanth.pinnit.data.PinnitNotification 5 | 6 | object NotificationsDiffCallback : DiffUtil.ItemCallback() { 7 | override fun areItemsTheSame(oldItem: PinnitNotification, newItem: PinnitNotification): Boolean { 8 | return oldItem.uuid == newItem.uuid 9 | } 10 | 11 | override fun areContentsTheSame(oldItem: PinnitNotification, newItem: PinnitNotification): Boolean { 12 | return oldItem == newItem 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/notifications/adapter/NotificationsItemTouchHelper.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications.adapter 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.drawable.ColorDrawable 6 | import androidx.core.content.ContextCompat 7 | import androidx.recyclerview.widget.ItemTouchHelper 8 | import androidx.recyclerview.widget.ItemTouchHelper.LEFT 9 | import androidx.recyclerview.widget.ItemTouchHelper.RIGHT 10 | import androidx.recyclerview.widget.RecyclerView 11 | import dev.sasikanth.pinnit.R 12 | import dev.sasikanth.pinnit.data.PinnitNotification 13 | import dev.sasikanth.pinnit.notifications.adapter.NotificationsListAdapter.NotificationViewHolder 14 | import dev.sasikanth.pinnit.utils.px 15 | import dev.sasikanth.pinnit.utils.resolveColor 16 | 17 | class NotificationsItemTouchHelper( 18 | context: Context, 19 | private val adapter: NotificationsListAdapter, 20 | private val onItemSwiped: (notification: PinnitNotification) -> Unit 21 | ) : ItemTouchHelper.SimpleCallback(0, LEFT or RIGHT) { 22 | 23 | private val colorDrawable = ColorDrawable( 24 | context.resolveColor(attrRes = R.attr.colorRowBackground) 25 | ) 26 | private val icon = ContextCompat.getDrawable(context, R.drawable.ic_pinnit_delete) 27 | 28 | override fun isItemViewSwipeEnabled(): Boolean { 29 | return true 30 | } 31 | 32 | override fun isLongPressDragEnabled(): Boolean { 33 | return false 34 | } 35 | 36 | override fun getSwipeDirs( 37 | recyclerView: RecyclerView, 38 | viewHolder: RecyclerView.ViewHolder 39 | ): Int { 40 | if (viewHolder is NotificationViewHolder) { 41 | val notification = adapter.currentList[viewHolder.adapterPosition] 42 | if (notification.isPinned) { 43 | return 0 44 | } 45 | } 46 | return super.getSwipeDirs(recyclerView, viewHolder) 47 | } 48 | 49 | override fun onMove( 50 | recyclerView: RecyclerView, 51 | viewHolder: RecyclerView.ViewHolder, 52 | target: RecyclerView.ViewHolder 53 | ): Boolean { 54 | return false 55 | } 56 | 57 | override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { 58 | val adapterPosition = viewHolder.adapterPosition 59 | if (adapterPosition != RecyclerView.NO_POSITION) { 60 | if (viewHolder is NotificationViewHolder) { 61 | val notification = adapter.currentList[adapterPosition] 62 | onItemSwiped(notification) 63 | } 64 | } 65 | } 66 | 67 | override fun onChildDraw( 68 | c: Canvas, 69 | recyclerView: RecyclerView, 70 | viewHolder: RecyclerView.ViewHolder, 71 | dX: Float, 72 | dY: Float, 73 | actionState: Int, 74 | isCurrentlyActive: Boolean 75 | ) { 76 | val itemView = viewHolder.itemView 77 | val iconMarginH = 24.px 78 | val cellHeight = itemView.bottom - itemView.top 79 | val iconWidth = icon!!.intrinsicWidth 80 | val iconHeight = icon.intrinsicHeight 81 | val iconTop = itemView.top + (cellHeight - iconHeight) / 2 82 | val iconBottom = iconTop + iconHeight 83 | 84 | val iconLeft: Int 85 | val iconRight: Int 86 | 87 | if (dX > 0) { 88 | // Right Swipe 89 | colorDrawable.setBounds(itemView.left, itemView.top, dX.toInt(), itemView.bottom) 90 | iconLeft = iconMarginH 91 | iconRight = iconMarginH + iconWidth 92 | } else { 93 | // Left Swipe 94 | colorDrawable.setBounds( 95 | itemView.right + dX.toInt(), 96 | itemView.top, 97 | itemView.right, 98 | itemView.bottom 99 | ) 100 | iconLeft = itemView.right - iconMarginH - iconWidth 101 | iconRight = itemView.right - iconMarginH 102 | } 103 | 104 | icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) 105 | 106 | colorDrawable.draw(c) 107 | icon.draw(c) 108 | 109 | super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/oemwarning/OemChecker.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.oemwarning 2 | 3 | private const val BRAND_XIAOMI = "xiaomi" 4 | private const val BRAND_REDMI = "redmi" 5 | private const val BRAND_HONOR = "honor" 6 | private const val BRAND_HUAWEI = "huawei" 7 | private const val BRAND_OPPO = "oppo" 8 | private const val BRAND_VIVO = "vivo" 9 | private const val BRAND_ONEPLUS = "oneplus" 10 | private const val BRAND_MEIZU = "meizu" 11 | 12 | private val warningOems = arrayOf( 13 | BRAND_XIAOMI, 14 | BRAND_REDMI, 15 | BRAND_HONOR, 16 | BRAND_HUAWEI, 17 | BRAND_OPPO, 18 | BRAND_VIVO, 19 | BRAND_ONEPLUS, 20 | BRAND_MEIZU 21 | ) 22 | 23 | fun shouldShowWarningForOEM(oemName: String): Boolean { 24 | return warningOems.contains(oemName) 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/oemwarning/OemWarningDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.oemwarning 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Dialog 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Build 8 | import android.os.Bundle 9 | import android.view.LayoutInflater 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import androidx.fragment.app.DialogFragment 13 | import androidx.fragment.app.FragmentManager 14 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 15 | import dev.sasikanth.pinnit.databinding.PinnitOemWarningDialogBinding 16 | 17 | class OemWarningDialog : DialogFragment() { 18 | 19 | companion object { 20 | private const val TAG = "OEM_WARNING_DIALOG" 21 | private val DONT_KILL_MY_APP_LINK = "https://dontkillmyapp.com/${Build.BRAND}" 22 | 23 | fun show(fragmentManager: FragmentManager) { 24 | OemWarningDialog() 25 | .show(fragmentManager, TAG) 26 | } 27 | } 28 | 29 | private var _binding: PinnitOemWarningDialogBinding? = null 30 | private val binding get() = _binding!! 31 | 32 | @SuppressLint("InflateParams") 33 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 34 | _binding = PinnitOemWarningDialogBinding.inflate(layoutInflater, null, false) 35 | 36 | return MaterialAlertDialogBuilder(requireContext()) 37 | .setView(binding.root) 38 | .create() 39 | } 40 | 41 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { 42 | return binding.root 43 | } 44 | 45 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 46 | super.onViewCreated(view, savedInstanceState) 47 | binding.showInstructionsButton.setOnClickListener { 48 | showInstructions() 49 | dismiss() 50 | } 51 | } 52 | 53 | override fun onDestroyView() { 54 | _binding = null 55 | super.onDestroyView() 56 | } 57 | 58 | private fun showInstructions() { 59 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(DONT_KILL_MY_APP_LINK)) 60 | startActivity(intent) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/options/OptionsBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.options 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.annotation.IdRes 8 | import androidx.datastore.core.DataStore 9 | import androidx.fragment.app.FragmentManager 10 | import androidx.lifecycle.Lifecycle 11 | import androidx.lifecycle.lifecycleScope 12 | import androidx.lifecycle.repeatOnLifecycle 13 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment 14 | import dagger.hilt.android.AndroidEntryPoint 15 | import dev.sasikanth.pinnit.R 16 | import dev.sasikanth.pinnit.data.preferences.AppPreferences 17 | import dev.sasikanth.pinnit.databinding.ThemeSelectionSheetBinding 18 | import dev.sasikanth.pinnit.utils.DispatcherProvider 19 | import kotlinx.coroutines.flow.filter 20 | import kotlinx.coroutines.flow.first 21 | import kotlinx.coroutines.flow.map 22 | import kotlinx.coroutines.launch 23 | import kotlinx.coroutines.withContext 24 | import reactivecircus.flowbinding.material.buttonCheckedChanges 25 | import javax.inject.Inject 26 | 27 | @AndroidEntryPoint 28 | class OptionsBottomSheet : BottomSheetDialogFragment() { 29 | 30 | @Inject 31 | lateinit var appPreferencesStore: DataStore 32 | 33 | @Inject 34 | lateinit var dispatcherProvider: DispatcherProvider 35 | 36 | companion object { 37 | private const val TAG = "OptionsBottomSheet" 38 | 39 | fun show(fragmentManager: FragmentManager) { 40 | OptionsBottomSheet() 41 | .show(fragmentManager, TAG) 42 | } 43 | } 44 | 45 | private var _binding: ThemeSelectionSheetBinding? = null 46 | private val binding get() = _binding!! 47 | 48 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 49 | _binding = ThemeSelectionSheetBinding.inflate(layoutInflater, container, false) 50 | return _binding?.root 51 | } 52 | 53 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 54 | super.onViewCreated(view, savedInstanceState) 55 | 56 | viewLifecycleOwner.lifecycleScope.launch { 57 | repeatOnLifecycle(Lifecycle.State.RESUMED) { 58 | val theme = withContext(dispatcherProvider.io) { 59 | appPreferencesStore.data.first().theme 60 | } 61 | checkThemeSelection(theme) 62 | 63 | binding.themeButtonGroup 64 | .buttonCheckedChanges() 65 | .filter { it.checked } 66 | .map { it.checkedId } 67 | .collect { updateTheme(it) } 68 | } 69 | } 70 | } 71 | 72 | override fun onDestroyView() { 73 | _binding = null 74 | super.onDestroyView() 75 | } 76 | 77 | private fun checkThemeSelection(theme: AppPreferences.Theme) { 78 | val id = when (theme) { 79 | AppPreferences.Theme.AUTO -> R.id.darkModeAuto 80 | AppPreferences.Theme.LIGHT -> R.id.darkModeOff 81 | AppPreferences.Theme.DARK -> R.id.darkModeOn 82 | else -> R.id.darkModeAuto 83 | } 84 | 85 | binding.themeButtonGroup.check(id) 86 | } 87 | 88 | private suspend fun updateTheme(@IdRes checkedId: Int) { 89 | val theme = when (checkedId) { 90 | R.id.darkModeOn -> AppPreferences.Theme.DARK 91 | R.id.darkModeOff -> AppPreferences.Theme.LIGHT 92 | R.id.darkModeAuto -> AppPreferences.Theme.AUTO 93 | else -> throw IllegalArgumentException("Unknown theme selection") 94 | } 95 | 96 | appPreferencesStore.updateData { currentData -> 97 | currentData.toBuilder() 98 | .setTheme(theme) 99 | .build() 100 | } 101 | 102 | dismiss() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/scheduler/PinnitNotificationScheduler.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.scheduler 2 | 3 | import androidx.work.ExistingWorkPolicy 4 | import androidx.work.WorkManager 5 | import dev.sasikanth.pinnit.data.PinnitNotification 6 | import dev.sasikanth.pinnit.utils.UserClock 7 | import dev.sasikanth.pinnit.worker.ScheduleWorker 8 | import java.time.LocalDateTime 9 | import java.util.UUID 10 | import javax.inject.Inject 11 | 12 | class PinnitNotificationScheduler @Inject constructor( 13 | private val workManager: WorkManager, 14 | private val userClock: UserClock 15 | ) { 16 | 17 | fun scheduleNotification(notification: PinnitNotification) { 18 | if (notification.schedule == null) return 19 | 20 | val now = LocalDateTime.now(userClock) 21 | val scheduleDateTime = notification.schedule.scheduleDate!!.atTime(notification.schedule.scheduleTime) 22 | 23 | val isInFuture = scheduleDateTime.isAfter(now) 24 | 25 | if (isInFuture.not()) return 26 | 27 | val workRequest = ScheduleWorker.scheduleNotificationRequest( 28 | notificationUuid = notification.uuid, 29 | schedule = notification.schedule, 30 | userClock = userClock 31 | ) 32 | 33 | workManager 34 | .enqueueUniqueWork( 35 | ScheduleWorker.tag(notificationUuid = notification.uuid), 36 | ExistingWorkPolicy.REPLACE, 37 | workRequest 38 | ) 39 | } 40 | 41 | fun cancel(notificationUuid: UUID) { 42 | workManager.cancelAllWorkByTag(ScheduleWorker.tag(notificationUuid = notificationUuid)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import android.content.Context 4 | import androidx.annotation.AttrRes 5 | import androidx.annotation.ColorInt 6 | import androidx.annotation.ColorRes 7 | import androidx.core.content.ContextCompat 8 | 9 | // Source: https://github.com/afollestad/material-dialogs/blob/66582d9993bcf55bfea873b4bf484a429ce3df36/core/src/main/java/com/afollestad/materialdialogs/utils/MDUtil.kt#L103 10 | @ColorInt 11 | fun Context.resolveColor( 12 | @ColorRes colorRes: Int? = null, 13 | @AttrRes attrRes: Int? = null, 14 | fallback: (() -> Int)? = null 15 | ): Int { 16 | if (attrRes != null) { 17 | val a = theme.obtainStyledAttributes(intArrayOf(attrRes)) 18 | try { 19 | val result = a.getColor(0, 0) 20 | if (result == 0 && fallback != null) { 21 | return fallback() 22 | } 23 | return result 24 | } finally { 25 | a.recycle() 26 | } 27 | } 28 | return ContextCompat.getColor(this, colorRes ?: 0) 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/DatabaseExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import androidx.sqlite.db.SupportSQLiteDatabase 4 | 5 | fun SupportSQLiteDatabase.inTransaction(block: SupportSQLiteDatabase.() -> Unit) { 6 | beginTransaction() 7 | try { 8 | block.invoke(this) 9 | setTransactionSuccessful() 10 | } finally { 11 | endTransaction() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/DispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | interface DispatcherProvider { 7 | val main: CoroutineDispatcher 8 | val io: CoroutineDispatcher 9 | val default: CoroutineDispatcher 10 | } 11 | 12 | class CoroutineDispatcherProvider : DispatcherProvider { 13 | 14 | override val main: CoroutineDispatcher 15 | get() = Dispatchers.Main 16 | 17 | override val io: CoroutineDispatcher 18 | get() = Dispatchers.IO 19 | 20 | override val default: CoroutineDispatcher 21 | get() = Dispatchers.Default 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/DrawableExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import android.os.CountDownTimer 4 | import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable 5 | 6 | fun SeekableAnimatedVectorDrawable.reverse() { 7 | object : CountDownTimer(totalDuration, 1) { 8 | override fun onTick(interval: Long) { 9 | currentPlayTime = interval 10 | } 11 | 12 | override fun onFinish() { 13 | currentPlayTime = 0 14 | } 15 | }.start() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/Globals.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | const val MAX_VIEW_EFFECTS_QUEUE_SIZE = 100 4 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/UserClock.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import java.time.Clock 4 | import java.time.Instant 5 | import java.time.ZoneId 6 | 7 | // Source: https://github.com/simpledotorg/simple-android/blob/45d4b34019b31328b7593590ee5f1a2095638206/app/src/main/java/org/simple/clinic/util/Clocks.kt 8 | abstract class UserClock : Clock() 9 | 10 | class RealUserClock(private val userTimeZone: ZoneId) : UserClock() { 11 | 12 | private val userClock = system(userTimeZone) 13 | 14 | override fun getZone(): ZoneId = userTimeZone 15 | 16 | override fun withZone(zoneId: ZoneId?): Clock = userClock.withZone(zoneId) 17 | 18 | override fun instant(): Instant = userClock.instant() 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/UtcClock.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import java.time.Clock 4 | import java.time.Instant 5 | import java.time.ZoneId 6 | 7 | // Source: https://github.com/simpledotorg/simple-android/blob/45d4b34019b31328b7593590ee5f1a2095638206/app/src/main/java/org/simple/clinic/util/Clocks.kt 8 | open class UtcClock : Clock() { 9 | 10 | private val utcClock = systemUTC() 11 | 12 | override fun withZone(zoneId: ZoneId?): Clock = utcClock.withZone(zoneId) 13 | 14 | override fun getZone(): ZoneId = utcClock.zone 15 | 16 | override fun instant(): Instant = utcClock.instant() 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/ViewExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import android.content.res.Resources 4 | 5 | val Int.px: Int 6 | get() = (Resources.getSystem().displayMetrics.density * this).toInt() 7 | 8 | val Int.dp: Int 9 | get() = (this / Resources.getSystem().displayMetrics.density).toInt() 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/room/JavaTimeRoomTypeConverters.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils.room 2 | 3 | import androidx.room.TypeConverter 4 | import java.time.Instant 5 | import java.time.LocalDate 6 | import java.time.LocalTime 7 | 8 | class InstantRoomTypeConverter { 9 | 10 | @TypeConverter 11 | fun toInstant(value: String?): Instant? { 12 | return value?.let { Instant.parse(value) } 13 | } 14 | 15 | @TypeConverter 16 | fun fromInstant(instant: Instant?): String? { 17 | return instant?.toString() 18 | } 19 | } 20 | 21 | class LocalDateRoomTypeConverter { 22 | 23 | @TypeConverter 24 | fun toLocalDate(value: String?): LocalDate? { 25 | return value?.let { LocalDate.parse(value) } 26 | } 27 | 28 | @TypeConverter 29 | fun fromLocalDate(localDate: LocalDate?): String? { 30 | return localDate?.toString() 31 | } 32 | } 33 | 34 | class LocalTimeRoomTypeConverter { 35 | 36 | @TypeConverter 37 | fun toLocalTime(value: String?): LocalTime? { 38 | return value?.let { LocalTime.parse(value) } 39 | } 40 | 41 | @TypeConverter 42 | fun fromLocalTime(localTime: LocalTime?): String? { 43 | return localTime?.toString() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/utils/room/UuidRoomTypeConverter.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils.room 2 | 3 | import androidx.room.TypeConverter 4 | import java.util.UUID 5 | 6 | class UuidRoomTypeConverter { 7 | 8 | @TypeConverter 9 | fun toUuid(value: String?): UUID? { 10 | return value?.let { UUID.fromString(value) } 11 | } 12 | 13 | @TypeConverter 14 | fun fromUuid(uuid: UUID?): String? { 15 | return uuid?.toString() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sasikanth/pinnit/widgets/CheckableImageView.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.widgets 2 | 3 | import android.content.Context 4 | import android.os.Parcel 5 | import android.os.Parcelable 6 | import android.os.Parcelable.ClassLoaderCreator 7 | import android.os.Parcelable.Creator 8 | import android.util.AttributeSet 9 | import android.view.View 10 | import android.view.accessibility.AccessibilityEvent 11 | import android.widget.Checkable 12 | import androidx.appcompat.widget.AppCompatImageView 13 | import androidx.customview.view.AbsSavedState 14 | 15 | /** 16 | * [AppCompatImageView] that implements [Checkable] interface to support 17 | * setting whether or not image is checked. 18 | */ 19 | class CheckableImageView( 20 | context: Context, 21 | attrs: AttributeSet? = null 22 | ) : AppCompatImageView(context, attrs), Checkable { 23 | private val checkStateSet = intArrayOf(android.R.attr.state_checked) 24 | private var checked = false 25 | private var checkable = true 26 | 27 | override fun setChecked(checked: Boolean) { 28 | if (checkable && this.checked != checked) { 29 | this.checked = checked 30 | refreshDrawableState() 31 | sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) 32 | } 33 | } 34 | 35 | override fun isChecked(): Boolean = checked 36 | 37 | override fun toggle() { 38 | isChecked = !checked 39 | } 40 | 41 | override fun onCreateDrawableState(extraSpace: Int): IntArray? { 42 | return if (checked) { 43 | View.mergeDrawableStates( 44 | super.onCreateDrawableState(extraSpace + checkStateSet.size), 45 | checkStateSet 46 | ) 47 | } else { 48 | super.onCreateDrawableState(extraSpace) 49 | } 50 | } 51 | 52 | override fun onSaveInstanceState(): Parcelable { 53 | val superState = super.onSaveInstanceState() 54 | val savedState = SavedState(superState) 55 | savedState.checked = checked 56 | return savedState 57 | } 58 | 59 | override fun onRestoreInstanceState(state: Parcelable?) { 60 | if (state !is SavedState) { 61 | super.onRestoreInstanceState(state) 62 | return 63 | } 64 | super.onRestoreInstanceState(state.superState) 65 | isChecked = state.checked 66 | } 67 | 68 | class SavedState : AbsSavedState { 69 | constructor(superState: Parcelable?) : super(superState!!) 70 | constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { 71 | readFromParcel(source) 72 | } 73 | 74 | internal var checked = false 75 | 76 | override fun writeToParcel(out: Parcel, flags: Int) { 77 | super.writeToParcel(out, flags) 78 | out.writeInt(if (checked) 1 else 0) 79 | } 80 | 81 | private fun readFromParcel(`in`: Parcel) { 82 | checked = `in`.readInt() == 1 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/proto/app_preferences.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "dev.sasikanth.pinnit.data.preferences"; 4 | option java_multiple_files = true; 5 | 6 | message AppPreferences { 7 | enum Theme { 8 | UNSPECIFIED = 0; 9 | AUTO = 1; 10 | LIGHT = 2; 11 | DARK = 3; 12 | } 13 | 14 | // User selected app theme 15 | Theme theme = 1; 16 | 17 | bool oem_warning_dialog = 2; 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/anim/pinnit_bottom_sheet_slide_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 20 | 21 | 24 | 25 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/anim/pinnit_bottom_sheet_slide_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 20 | 21 | 24 | 25 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/color/about_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/button_group_background_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/color/button_group_stroke_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/color/button_group_text_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/color/color_notification_divider.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/color_on_background_15.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/color/color_on_background_70.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/color/color_secondary_20.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/color/color_secondary_5.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/color/edit_text_stroke_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/color/material_button_background_tint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/material_button_text_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/schedule_checkbox_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/color/schedule_indicator_icon_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/schedule_indicator_stroke_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/schedule_indicator_text_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-nodpi/pinnit_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/drawable-nodpi/pinnit_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-v26/ic_shortcut_create.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/about_item_ripple.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/asld_pin_unpin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bottom_bar_item_ripple.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/divider.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_back.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_drop_down.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 13 | 16 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground_themed.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_about.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_add.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 17 | 21 | 22 | 26 | 33 | 39 | 46 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_app_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_dark_mode.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_date.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_delete.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_email.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_notification.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_pin.xml: -------------------------------------------------------------------------------- 1 | 7 | 13 | 16 | 23 | 30 | 36 | 37 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_pinned.xml: -------------------------------------------------------------------------------- 1 | 7 | 13 | 16 | 23 | 30 | 35 | 38 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_source_code.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_warning.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pinnit_warning_80dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_qs_app_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_shortcut_create.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_shortcut_create_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_shortcut_create_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/illustration_pinnit_no_notifications.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/illustration_pinnit_pin.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/notification_divider.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rectangle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/font/bai_jam_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/font/bai_jam_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/bai_jam_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/font/bai_jam_medium.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/bai_jam_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/font/bai_jam_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/bai_jam_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/font/bai_jam_semibold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/jura_bold.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/font/jura_semibold.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/layout/pinnit_bottom_bar.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 23 | 24 | 39 | 40 | 53 | 54 | 69 | 70 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /app/src/main/res/layout/pinnit_oem_warning_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 17 | 18 | 28 | 29 | 39 | 40 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/res/layout/theme_selection_sheet.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 18 | 19 | 26 | 27 | 35 | 36 | 37 | 38 | 51 | 52 | 59 | 60 | 67 | 68 | 75 | 76 | 77 | 78 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /app/src/main/res/menu/notification_schedule.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/navigation/main_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 15 | 16 | 21 | 26 | 31 | 36 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/res/values-land/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32dp 5 | 32dp 6 | 144dp 7 | 144dp 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @color/maroon_600 4 | @color/maroon_700 5 | 6 | @color/maroon_200 7 | @android:color/black 8 | 9 | @color/maroon_800 10 | @android:color/white 11 | 12 | @color/maroon_900 13 | @android:color/white 14 | 15 | #6614000E 16 | 17 | 18 | @color/maroon_150 19 | 20 | #CC000000 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/sys_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | false 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt-rBR/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Notificações 4 | Sem notificações 5 | Título 6 | Descrição 7 | Notificações fixadas 8 | Notificação deletada 9 | 10 | Criar 11 | Editar 12 | 13 | Criar 14 | Salvar e fixar 15 | Salvar 16 | 17 | Lembrete 18 | Repetir a cada… 19 | Dia 20 | Semana 21 | Mês 22 | 23 | %1$s - %2$s 24 | Hoje - %1$s 25 | Ontem - %1$s 26 | Amanhã - %1$s 27 | Editar lembrete 28 | Remover lembrete 29 | Este lembrete está no passado. Delete ou edite-o para uma data futura. 30 | 31 | Modo noturno 32 | 33 | 34 | Ativado 35 | Auto 36 | Desativado 37 | 38 | 39 | Sobre 40 | Política de privacidade 41 | Questões? 42 | Contatar suporte 43 | Aviso sobre seu aparelho 44 | Enviar email 45 | Sim 46 | Não 47 | Desfazer 48 | 49 | 50 | Desafixar 51 | Deletar 52 | 53 | 54 | Descartar mudanças? 55 | Sim 56 | Não 57 | Deletar notificação? 58 | Essa ação não pode ser desfeita. 59 | Sim 60 | Não 61 | 62 | 63 | Abrir app 64 | 65 | 66 | Aviso sobre o seu aparelho 67 | 68 | Algumas fabricantes são conhecidas por fechar aplicativos de maneira agressiva. Pinnit não roda em segundo plano, mas se for fechado pelo sistema, notificações vindas de Pinnit serão retiradas do seu painel e só retornarão quando o app for lançado novamente. 69 | \n 70 | \nPara evitar isto, visite o link abaixo e siga as instruções. 71 | 72 | MOSTRAR INSTRUÇÕES 73 | VERSÃO %1$s 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/values-v27/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values-v27/theme.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pin $text 5 | add $text 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #1F0014 5 | #380025 6 | #4D0033 7 | #660044 8 | #852163 9 | #A64B87 10 | #C787B1 11 | #E5B3D4 12 | #F5D0E9 13 | #FFE5F6 14 | 15 | @color/maroon_700 16 | @color/maroon_800 17 | 18 | @android:color/white 19 | @color/maroon_100 20 | 21 | @color/maroon_700 22 | @android:color/white 23 | 24 | @android:color/white 25 | @color/maroon_900 26 | 27 | @android:color/white 28 | @color/maroon_900 29 | 30 | #9C1664 31 | @android:color/white 32 | 33 | #B3FFFFFF 34 | 35 | 36 | @color/maroon_700 37 | 38 | 39 | #9C1664 40 | 41 | @color/color_on_background_15 42 | @color/color_on_background_70 43 | 44 | #80000000 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 56dp 4 | 2dp 5 | 6 | 7 | 144dp 8 | 144dp 9 | 32dp 10 | 32dp 11 | 12 | 13 | 72dp 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/values/floats.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0.4 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/font_certs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @array/com_google_android_gms_fonts_certs_dev 5 | @array/com_google_android_gms_fonts_certs_prod 6 | 7 | 8 | 9 | MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= 10 | 11 | 12 | 13 | 14 | MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/pinnit_bottom_bar.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/sys_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | true 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/xml-v25/shortcuts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/xml/actions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/CanaryTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | 6 | class CanaryTest { 7 | @Test 8 | fun canaryTest() { 9 | assertThat(true).isTrue() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/editor/EditorScreenInitTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import com.spotify.mobius.test.FirstMatchers.hasEffects 4 | import com.spotify.mobius.test.FirstMatchers.hasModel 5 | import com.spotify.mobius.test.FirstMatchers.hasNoEffects 6 | import com.spotify.mobius.test.InitSpec 7 | import com.spotify.mobius.test.InitSpec.assertThatFirst 8 | import dev.sasikanth.sharedtestcode.TestData 9 | import org.junit.Test 10 | import java.util.UUID 11 | 12 | class EditorScreenInitTest { 13 | 14 | private val initSpec = InitSpec(EditorScreenInit()) 15 | private val notificationUuid = UUID.fromString("97f6ee65-b2c6-403f-97aa-ca45ebfa444b") 16 | 17 | @Test 18 | fun `when screen is created and notification uuid is present, then fetch notification`() { 19 | val defaultModel = EditorScreenModel.default(notificationUuid, null, null) 20 | 21 | initSpec 22 | .whenInit(defaultModel) 23 | .then( 24 | assertThatFirst( 25 | hasModel(defaultModel), 26 | hasEffects(LoadNotification(notificationUuid) as EditorScreenEffect) 27 | ) 28 | ) 29 | } 30 | 31 | @Test 32 | fun `when screen is created and notification uuid is not present and title and content is null, then set empty title and content`() { 33 | val defaultModel = EditorScreenModel.default(null, null, null) 34 | 35 | initSpec 36 | .whenInit(defaultModel) 37 | .then( 38 | assertThatFirst( 39 | hasModel(defaultModel), 40 | hasEffects(SetTitleAndContent(defaultModel.title, defaultModel.content) as EditorScreenEffect) 41 | ) 42 | ) 43 | } 44 | 45 | @Test 46 | fun `when screen is restored and notification is already loaded, then do nothing`() { 47 | val notification = dev.sasikanth.sharedtestcode.TestData.notification( 48 | uuid = notificationUuid, 49 | title = "Notification Title" 50 | ) 51 | 52 | val defaultModel = EditorScreenModel.default(notificationUuid, null, null) 53 | .notificationLoaded(notification) 54 | .titleChanged(notification.title) 55 | 56 | initSpec 57 | .whenInit(defaultModel) 58 | .then( 59 | assertThatFirst( 60 | hasModel(defaultModel), 61 | hasNoEffects() 62 | ) 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/editor/ScheduleValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.editor 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import dev.sasikanth.pinnit.editor.ScheduleValidator.Result.ScheduleInPastError 5 | import dev.sasikanth.pinnit.editor.ScheduleValidator.Result.Valid 6 | import dev.sasikanth.sharedtestcode.utils.TestUserClock 7 | import org.junit.Test 8 | import java.time.Instant 9 | import java.time.LocalDate 10 | import java.time.LocalTime 11 | 12 | class ScheduleValidatorTest { 13 | 14 | private val userClock = dev.sasikanth.sharedtestcode.utils.TestUserClock(Instant.parse("2020-01-01T00:00:00.00Z")) 15 | private val validator = ScheduleValidator(userClock) 16 | 17 | @Test 18 | fun `when schedule is in future, then return schedule is valid`() { 19 | // given 20 | val scheduleDate = LocalDate.parse("2020-01-01") 21 | val scheduleTime = LocalTime.parse("09:00:00") 22 | 23 | // when 24 | val expectedResult = validator.validate(scheduleDate, scheduleTime) 25 | 26 | // then 27 | assertThat(expectedResult).isEqualTo(Valid) 28 | } 29 | 30 | @Test 31 | fun `when schedule is in past, then return schedule in past error`() { 32 | // given 33 | val scheduleDate = LocalDate.parse("2019-12-31") 34 | val scheduleTime = LocalTime.parse("09:00:00") 35 | 36 | // when 37 | val expectedResult = validator.validate(scheduleDate, scheduleTime) 38 | 39 | // then 40 | assertThat(expectedResult).isEqualTo(ScheduleInPastError) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInitTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import com.spotify.mobius.test.FirstMatchers.hasEffects 4 | import com.spotify.mobius.test.FirstMatchers.hasModel 5 | import com.spotify.mobius.test.FirstMatchers.hasNoEffects 6 | import com.spotify.mobius.test.InitSpec 7 | import com.spotify.mobius.test.InitSpec.assertThatFirst 8 | import dev.sasikanth.sharedtestcode.TestData 9 | import org.junit.Test 10 | import java.util.UUID 11 | 12 | class NotificationsScreenInitTest { 13 | 14 | private val initSpec = InitSpec(NotificationsScreenInit()) 15 | private val defaultModel = NotificationsScreenModel.default() 16 | 17 | @Test 18 | fun `when screen is created, then load notifications`() { 19 | initSpec 20 | .whenInit(defaultModel) 21 | .then( 22 | assertThatFirst( 23 | hasModel(defaultModel), 24 | hasEffects(LoadNotifications, CheckNotificationsVisibility, CheckPermissionToPostNotification) 25 | ) 26 | ) 27 | } 28 | 29 | @Test 30 | fun `when screen is restored, then don't load notifications`() { 31 | val notifications = listOf( 32 | dev.sasikanth.sharedtestcode.TestData.notification( 33 | uuid = UUID.fromString("c59f2db1-2cfc-476c-9fa2-1fac99bd7336") 34 | ) 35 | ) 36 | val restoredModel = defaultModel 37 | .onNotificationsLoaded(notifications) 38 | 39 | initSpec 40 | .whenInit(restoredModel) 41 | .then( 42 | assertThatFirst( 43 | hasModel(restoredModel), 44 | hasEffects(CheckPermissionToPostNotification) 45 | ) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUiRenderTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.notifications 2 | 3 | import org.mockito.kotlin.mock 4 | import org.mockito.kotlin.verify 5 | import org.mockito.kotlin.verifyNoMoreInteractions 6 | import org.mockito.kotlin.verifyNoInteractions 7 | import dev.sasikanth.sharedtestcode.TestData 8 | import org.junit.Test 9 | import java.util.UUID 10 | 11 | class NotificationsScreenUiRenderTest { 12 | 13 | private val ui = mock() 14 | private val uiRender = NotificationsScreenUiRender(ui) 15 | private val defaultModel = NotificationsScreenModel.default() 16 | 17 | @Test 18 | fun `when notifications are being fetched, then render nothing`() { 19 | // when 20 | uiRender.render(defaultModel) 21 | 22 | // then 23 | verifyNoInteractions(ui) 24 | } 25 | 26 | @Test 27 | fun `show notifications and hide notification error if notifications are not empty`() { 28 | // given 29 | val notifications = listOf( 30 | dev.sasikanth.sharedtestcode.TestData.notification( 31 | uuid = UUID.fromString("29238d40-c5c8-44e6-94f3-9b40fd0314b3") 32 | ) 33 | ) 34 | 35 | // when 36 | uiRender.render(defaultModel.onNotificationsLoaded(notifications)) 37 | 38 | // then 39 | verify(ui).hideNotificationsEmptyError() 40 | verify(ui).showNotifications(notifications) 41 | verifyNoMoreInteractions(ui) 42 | } 43 | 44 | @Test 45 | fun `show notifications error and hide notifications if notifications are empty`() { 46 | // when 47 | uiRender.render(defaultModel.onNotificationsLoaded(emptyList())) 48 | 49 | // then 50 | verify(ui).showNotificationsEmptyError() 51 | verify(ui).hideNotifications() 52 | verifyNoMoreInteractions(ui) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/oemwarning/OemCheckerTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.oemwarning 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | 6 | class OemCheckerTest { 7 | 8 | @Test 9 | fun `should show warning for oem should work correctly`() { 10 | // given 11 | val onePlus = "oneplus" 12 | 13 | // when 14 | val shouldShowOemWarningDialog = shouldShowWarningForOEM(onePlus) 15 | 16 | // then 17 | assertThat(shouldShowOemWarningDialog).isTrue() 18 | } 19 | 20 | @Test 21 | fun `should show warning for oem should fail`() { 22 | // given 23 | val google = "google" 24 | 25 | // when 26 | val shouldShowOemWarningDialog = shouldShowWarningForOEM(google) 27 | 28 | // then 29 | assertThat(shouldShowOemWarningDialog).isFalse() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/test/java/dev/sasikanth/pinnit/utils/TestDispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.pinnit.utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 5 | 6 | class TestDispatcherProvider : DispatcherProvider { 7 | 8 | private val testCoroutineDispatcher = UnconfinedTestDispatcher() 9 | 10 | override val main: CoroutineDispatcher 11 | get() = testCoroutineDispatcher 12 | 13 | override val io: CoroutineDispatcher 14 | get() = testCoroutineDispatcher 15 | 16 | override val default: CoroutineDispatcher 17 | get() = testCoroutineDispatcher 18 | } 19 | -------------------------------------------------------------------------------- /app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // this is necessary to avoid the plugins to be loaded multiple times 3 | // in each subproject's classloader 4 | alias(libs.plugins.android.application).apply(false) 5 | alias(libs.plugins.android.library).apply(false) 6 | alias(libs.plugins.kotlin.android).apply(false) 7 | alias(libs.plugins.kotlin.parcelize).apply(false) 8 | alias(libs.plugins.compose.compiler).apply(false) 9 | alias(libs.plugins.androidx.navigation).apply(false) 10 | alias(libs.plugins.hilt).apply(false) 11 | alias(libs.plugins.protobuf).apply(false) 12 | alias(libs.plugins.ksp).apply(false) 13 | alias(libs.plugins.redacted).apply(false) 14 | } 15 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ## For more details on how to configure your build environment visit 2 | # http://www.gradle.org/docs/current/userguide/build_environment.html 3 | # 4 | # Specifies the JVM arguments used for the daemon process. 5 | # The setting is particularly useful for tweaking memory settings. 6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 8 | # 9 | # When configured, Gradle will run in incubating parallel mode. 10 | # This option should only be used with decoupled projects. More details, visit 11 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 12 | # org.gradle.parallel=true 13 | #Sat Oct 30 17:19:03 IST 2021 14 | kotlin.code.style=official 15 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" 16 | android.useAndroidX=true 17 | android.enableJetifier=true 18 | android.nonTransitiveRClass=false 19 | android.nonFinalResIds=false 20 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/icon.png -------------------------------------------------------------------------------- /release/app-release.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/release/app-release.gpg -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "automergeType": "branch", 7 | "packageRules": [ 8 | { 9 | "description": "Automerge minor updates", 10 | "matchUpdateTypes": ["minor", "patch"], 11 | "automerge": true 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 2 | 3 | rootProject.name = "pinnit" 4 | 5 | pluginManagement { 6 | repositories { 7 | gradlePluginPortal() 8 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 9 | maven("https://oss.sonatype.org/content/repositories/snapshots") 10 | google() 11 | } 12 | } 13 | 14 | dependencyResolutionManagement { 15 | @Suppress("UnstableApiUsage") 16 | repositories { 17 | google() 18 | mavenCentral() 19 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 20 | maven("https://jitpack.io") 21 | maven("https://oss.sonatype.org/content/repositories/snapshots") 22 | } 23 | } 24 | 25 | include(":app") 26 | include(":sharedTestCode") 27 | -------------------------------------------------------------------------------- /sharedTestCode/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /sharedTestCode/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("org.jetbrains.kotlin.android") 4 | } 5 | 6 | android { 7 | namespace = "dev.sasikanth.sharedtestcode" 8 | compileSdk = libs.versions.sdk.compile.get().toInt() 9 | 10 | defaultConfig { 11 | minSdk = libs.versions.sdk.min.get().toInt() 12 | 13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 14 | } 15 | 16 | buildTypes { 17 | release { 18 | isMinifyEnabled = false 19 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 20 | } 21 | } 22 | compileOptions { 23 | isCoreLibraryDesugaringEnabled = true 24 | 25 | sourceCompatibility = JavaVersion.VERSION_17 26 | targetCompatibility = JavaVersion.VERSION_17 27 | } 28 | kotlinOptions { 29 | jvmTarget = JavaVersion.VERSION_17.toString() 30 | } 31 | } 32 | 33 | dependencies { 34 | implementation(projects.app) 35 | 36 | coreLibraryDesugaring(libs.desugar.jdk) 37 | 38 | testImplementation(libs.junit) 39 | androidTestImplementation(libs.androidx.junit) 40 | androidTestImplementation(libs.espresso) 41 | } 42 | -------------------------------------------------------------------------------- /sharedTestCode/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msasikanth/pinnit/0cf29aa1317056ff15ccc269e437e42d8da6d80f/sharedTestCode/consumer-rules.pro -------------------------------------------------------------------------------- /sharedTestCode/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /sharedTestCode/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sharedTestCode/src/main/java/dev/sasikanth/sharedtestcode/TestData.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.sharedtestcode 2 | 3 | import dev.sasikanth.pinnit.data.PinnitNotification 4 | import dev.sasikanth.pinnit.data.Schedule 5 | import dev.sasikanth.pinnit.data.ScheduleType 6 | import java.time.Instant 7 | import java.time.LocalDate 8 | import java.time.LocalTime 9 | import java.util.UUID 10 | 11 | object TestData { 12 | fun notification( 13 | uuid: UUID = UUID.randomUUID(), 14 | title: String = "Notification Title", 15 | content: String? = null, 16 | isPinned: Boolean = false, 17 | createdAt: Instant = Instant.now(), 18 | updatedAt: Instant = Instant.now(), 19 | deletedAt: Instant? = null, 20 | scheduleDate: LocalDate = LocalDate.now(), 21 | scheduleTime: LocalTime = LocalTime.now(), 22 | scheduleType: ScheduleType? = ScheduleType.Daily, 23 | schedule: Schedule? = schedule( 24 | scheduleDate = scheduleDate, 25 | scheduleTime = scheduleTime, 26 | scheduleType = scheduleType 27 | ) 28 | ): PinnitNotification { 29 | return PinnitNotification( 30 | uuid = uuid, 31 | title = title, 32 | content = content, 33 | isPinned = isPinned, 34 | createdAt = createdAt, 35 | updatedAt = updatedAt, 36 | deletedAt = deletedAt, 37 | schedule = schedule 38 | ) 39 | } 40 | 41 | fun schedule( 42 | scheduleDate: LocalDate = LocalDate.now(), 43 | scheduleTime: LocalTime = LocalTime.now(), 44 | scheduleType: ScheduleType? = ScheduleType.Daily, 45 | ): Schedule { 46 | return Schedule( 47 | scheduleDate = scheduleDate, 48 | scheduleTime = scheduleTime, 49 | scheduleType = scheduleType 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sharedTestCode/src/main/java/dev/sasikanth/sharedtestcode/utils/TestUserClock.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.sharedtestcode.utils 2 | 3 | import dev.sasikanth.pinnit.utils.UserClock 4 | import java.time.Clock 5 | import java.time.Duration 6 | import java.time.Instant 7 | import java.time.LocalDate 8 | import java.time.ZoneId 9 | import java.time.ZoneOffset 10 | import java.time.ZoneOffset.UTC 11 | 12 | class TestUserClock(instant: Instant) : UserClock() { 13 | 14 | constructor() : this(Instant.EPOCH) 15 | 16 | constructor(localDate: LocalDate, zoneOffset: ZoneOffset = UTC) : this(instantFromDateAtZone(localDate, zoneOffset)) 17 | 18 | private var clock = fixed(instant, UTC) 19 | 20 | override fun withZone(zone: ZoneId): Clock = clock.withZone(zone) 21 | 22 | override fun getZone(): ZoneId = clock.zone 23 | 24 | override fun instant(): Instant = clock.instant() 25 | 26 | fun advanceBy(duration: Duration) { 27 | clock = offset(clock, duration) 28 | } 29 | 30 | fun setDate(date: LocalDate, zone: ZoneId = this.zone) { 31 | val instant = instantFromDateAtZone(date, zone) 32 | clock = fixed(instant, zone) 33 | } 34 | } 35 | 36 | private fun instantFromDateAtZone(localDate: LocalDate, zone: ZoneId): Instant { 37 | return localDate.atStartOfDay(zone).toInstant() 38 | } 39 | -------------------------------------------------------------------------------- /sharedTestCode/src/main/java/dev/sasikanth/sharedtestcode/utils/TestUtcClock.kt: -------------------------------------------------------------------------------- 1 | package dev.sasikanth.sharedtestcode.utils 2 | 3 | import dev.sasikanth.pinnit.utils.UtcClock 4 | import java.time.Clock 5 | import java.time.Duration 6 | import java.time.Instant 7 | import java.time.LocalDate 8 | import java.time.ZoneId 9 | import java.time.ZoneOffset.UTC 10 | 11 | class TestUtcClock(instant: Instant) : UtcClock() { 12 | 13 | constructor() : this(Instant.EPOCH) 14 | 15 | private var clock = fixed(instant, UTC) 16 | 17 | override fun withZone(zoneId: ZoneId?): Clock = clock.withZone(zoneId) 18 | 19 | override fun getZone(): ZoneId = UTC 20 | 21 | override fun instant(): Instant = clock.instant() 22 | 23 | fun setDate(date: LocalDate) { 24 | val instant = date.atStartOfDay(zone).toInstant() 25 | clock = fixed(instant, UTC) 26 | } 27 | 28 | fun advanceBy(duration: Duration) { 29 | clock = offset(clock, duration) 30 | } 31 | 32 | fun resetToEpoch() { 33 | clock = fixed(Instant.EPOCH, UTC) 34 | } 35 | } 36 | --------------------------------------------------------------------------------