├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── LICENSE.txt ├── README.md ├── app ├── build.gradle.kts ├── generate.py ├── lint.xml ├── proguard-rules.pro ├── src │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── atomicrobot │ │ │ └── carbon │ │ │ ├── EspressoMatchers.kt │ │ │ ├── RecyclerViewMatcher.kt │ │ │ ├── TestMainApplication.kt │ │ │ ├── TestUtils.kt │ │ │ └── ui │ │ │ ├── deeplink │ │ │ └── DeepLinkEspressoTest.kt │ │ │ └── main │ │ │ └── StartActivityEspressoTest.kt │ ├── debug │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── atomicrobot │ │ │ │ └── carbon │ │ │ │ ├── app │ │ │ │ ├── MainApplicationInitializer.kt │ │ │ │ └── RobolectricApplication.kt │ │ │ │ ├── modules │ │ │ │ └── CrashReporterModule.kt │ │ │ │ └── ui │ │ │ │ └── RiseAndShine.kt │ │ └── res │ │ │ ├── drawable-anydpi │ │ │ └── ic_bell_notification.xml │ │ │ ├── drawable-hdpi │ │ │ └── ic_bell_notification.png │ │ │ ├── drawable-mdpi │ │ │ └── ic_bell_notification.png │ │ │ ├── drawable-xhdpi │ │ │ └── ic_bell_notification.png │ │ │ └── drawable-xxhdpi │ │ │ └── ic_bell_notification.png │ ├── dev │ │ ├── AndroidManifest.xml │ │ ├── google-services.json │ │ ├── java │ │ │ └── com │ │ │ │ └── atomicrobot │ │ │ │ └── carbon │ │ │ │ ├── app │ │ │ │ ├── SSLDevelopmentHelper.kt │ │ │ │ ├── VariantModule.kt │ │ │ │ └── VariantSettings.kt │ │ │ │ └── ui │ │ │ │ └── devsettings │ │ │ │ └── DevSettingsViewModel.kt │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── assets │ │ │ └── licenses.md │ │ ├── java │ │ │ └── com │ │ │ │ └── atomicrobot │ │ │ │ └── carbon │ │ │ │ ├── Mockable.kt │ │ │ │ ├── StartActivity.kt │ │ │ │ ├── app │ │ │ │ ├── BaseApplicationInitializer.kt │ │ │ │ ├── MainApplication.kt │ │ │ │ ├── Modules.kt │ │ │ │ └── Settings.kt │ │ │ │ ├── data │ │ │ │ ├── api │ │ │ │ │ └── github │ │ │ │ │ │ ├── DetailedGitHubApiService.kt │ │ │ │ │ │ ├── GitHubApiService.kt │ │ │ │ │ │ ├── GitHubInteractor.kt │ │ │ │ │ │ └── model │ │ │ │ │ │ ├── Commit.kt │ │ │ │ │ │ └── DetailedCommit.kt │ │ │ │ └── lumen │ │ │ │ │ ├── LumenDatabase.kt │ │ │ │ │ ├── SceneModel.kt │ │ │ │ │ ├── dao │ │ │ │ │ ├── LightDao.kt │ │ │ │ │ ├── RoomDao.kt │ │ │ │ │ ├── SceneDao.kt │ │ │ │ │ └── SceneLightDao.kt │ │ │ │ │ └── dto │ │ │ │ │ ├── LumenLight.kt │ │ │ │ │ ├── LumenRoom.kt │ │ │ │ │ ├── LumenScene.kt │ │ │ │ │ └── SceneAndLight.kt │ │ │ │ ├── deeplink │ │ │ │ └── DeepLinkInteractor.kt │ │ │ │ ├── monitoring │ │ │ │ ├── CrashReporter.kt │ │ │ │ ├── CrashlyticsCrashReporter.kt │ │ │ │ └── LoggingOnlyCrashReporter.kt │ │ │ │ ├── navigation │ │ │ │ └── NavigableScreens.kt │ │ │ │ ├── notification │ │ │ │ └── Notification.kt │ │ │ │ ├── ui │ │ │ │ ├── about │ │ │ │ │ ├── AboutHtmlScreen.kt │ │ │ │ │ └── AboutScreen.kt │ │ │ │ ├── clickableCards │ │ │ │ │ ├── GitCardInfoScreen.kt │ │ │ │ │ ├── GitCardInfoViewModel.kt │ │ │ │ │ └── SampleData2.kt │ │ │ │ ├── components │ │ │ │ │ ├── AppBars.kt │ │ │ │ │ └── AtomicRobotUI.kt │ │ │ │ ├── deeplink │ │ │ │ │ └── DeepLinkSampleScreen.kt │ │ │ │ ├── license │ │ │ │ │ ├── LicenseScreen.kt │ │ │ │ │ └── LicenseViewModel.kt │ │ │ │ ├── lumen │ │ │ │ │ ├── LumenConverters.kt │ │ │ │ │ ├── LumenIndeterminateIndicator.kt │ │ │ │ │ ├── LumenSwitch.kt │ │ │ │ │ ├── home │ │ │ │ │ │ └── LumenHome.kt │ │ │ │ │ ├── navigation │ │ │ │ │ │ └── LumenNavigation.kt │ │ │ │ │ ├── routines │ │ │ │ │ │ └── LumenRoutines.kt │ │ │ │ │ ├── scenes │ │ │ │ │ │ ├── SceneElements.kt │ │ │ │ │ │ ├── ScenesScreen.kt │ │ │ │ │ │ └── ScenesViewModel.kt │ │ │ │ │ └── settings │ │ │ │ │ │ └── LumenSettings.kt │ │ │ │ ├── main │ │ │ │ │ ├── MainScreen.kt │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ └── SampleData.kt │ │ │ │ ├── navigation │ │ │ │ │ ├── Drawer.kt │ │ │ │ │ └── MainNavigation.kt │ │ │ │ ├── permission │ │ │ │ │ └── RequestPermission.kt │ │ │ │ ├── scanner │ │ │ │ │ ├── ScannerScreen.kt │ │ │ │ │ └── ScannerViewModel.kt │ │ │ │ ├── settings │ │ │ │ │ └── SettingsScreen.kt │ │ │ │ ├── shader │ │ │ │ │ └── AngledLinearGradient.kt │ │ │ │ ├── shell │ │ │ │ │ └── CarbonShellNavigation.kt │ │ │ │ ├── splash │ │ │ │ │ └── SplashViewModel.kt │ │ │ │ └── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ └── Type.kt │ │ │ │ └── util │ │ │ │ ├── AppLogger.kt │ │ │ │ ├── CarbonCompositionLocals.kt │ │ │ │ ├── Common.kt │ │ │ │ ├── ComposePreviewParameters.kt │ │ │ │ ├── MyFirebaseMessagingService.kt │ │ │ │ └── SharedPreferencesExtensions.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ ├── ic_lumen_color_bulb.png │ │ │ ├── ic_lumen_white_bulb.png │ │ │ └── lumen_project.png │ │ │ ├── drawable-mdpi │ │ │ ├── ic_lumen_color_bulb.png │ │ │ └── ic_lumen_white_bulb.png │ │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── drawable-xhdpi │ │ │ ├── ic_lumen_color_bulb.png │ │ │ └── ic_lumen_white_bulb.png │ │ │ ├── drawable-xxhdpi │ │ │ ├── ic_lumen_color_bulb.png │ │ │ └── ic_lumen_white_bulb.png │ │ │ ├── drawable-xxxhdpi │ │ │ ├── ic_lumen_color_bulb.png │ │ │ └── ic_lumen_white_bulb.png │ │ │ ├── drawable │ │ │ ├── blurple.xml │ │ │ ├── carbon_android_logo.xml │ │ │ ├── feature_about.png │ │ │ ├── ic_baseline_qr_code_scanner.xml │ │ │ ├── ic_bell_notification.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_loading_icon.xml │ │ │ ├── ic_lumen_add.xml │ │ │ ├── ic_lumen_bright_sun.xml │ │ │ ├── ic_lumen_bulb.xml │ │ │ ├── ic_lumen_clock.xml │ │ │ ├── ic_lumen_close.xml │ │ │ ├── ic_lumen_color.xml │ │ │ ├── ic_lumen_heart.xml │ │ │ ├── ic_lumen_heart_filled.xml │ │ │ ├── ic_lumen_home_icon.xml │ │ │ ├── ic_lumen_logo.xml │ │ │ ├── ic_lumen_meatball.xml │ │ │ ├── ic_lumen_play.xml │ │ │ ├── ic_lumen_play_filled.xml │ │ │ ├── ic_lumen_scene_icon.xml │ │ │ ├── ic_lumen_schedule_icon.xml │ │ │ ├── ic_lumen_stop_filled.xml │ │ │ ├── ic_lumen_timer.xml │ │ │ └── ic_lumen_trash.xml │ │ │ ├── font │ │ │ ├── lexend_bold.ttf │ │ │ ├── lexend_light.ttf │ │ │ ├── lexend_medium.ttf │ │ │ └── lexend_regular.ttf │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ │ └── xml │ │ │ └── backup_rules.xml │ ├── prod │ │ ├── AndroidManifest.xml │ │ ├── google-services.json │ │ └── java │ │ │ └── com │ │ │ └── atomicrobot │ │ │ └── carbon │ │ │ └── app │ │ │ ├── VariantModule.kt │ │ │ └── VariantSettings.kt │ ├── release │ │ └── java │ │ │ └── com │ │ │ └── atomicrobot │ │ │ └── carbon │ │ │ ├── app │ │ │ └── MainApplicationInitializer.kt │ │ │ ├── modules │ │ │ └── CrashReporterModule.kt │ │ │ └── monitoring │ │ │ └── model │ │ │ └── NoOpTree.kt │ └── test │ │ ├── java │ │ └── com │ │ │ └── atomicrobot │ │ │ └── carbon │ │ │ ├── RxTests.kt │ │ │ ├── SimpleRobolectricTest.kt │ │ │ ├── SimpleTests.kt │ │ │ ├── TestExtensions.kt │ │ │ ├── data │ │ │ └── api │ │ │ │ └── github │ │ │ │ ├── DeepLinkInteractorTest.kt │ │ │ │ ├── GitHubApiServiceTest.kt │ │ │ │ ├── GitHubCardApiServiceTest.kt │ │ │ │ ├── GitHubInteractorTest.kt │ │ │ │ └── model │ │ │ │ └── CommitTestHelper.kt │ │ │ └── ui │ │ │ └── main │ │ │ └── MainViewModelTest.kt │ │ ├── resources │ │ ├── api │ │ │ ├── listCommits_success.json │ │ │ └── testDetailedCommit_success.json │ │ ├── mockito-extensions │ │ │ └── org.mockito.plugins.MockMaker │ │ └── robolectric.properties │ │ └── sample_scanner_codes │ │ ├── barcode ar_5155.png │ │ ├── qr-code_black_18.png │ │ ├── qr-code_blue_48.png │ │ ├── qr-code_green_28.png │ │ └── qr-code_red_22.png └── templates │ └── screen │ ├── Activity.kt.mustache │ ├── ActivityTests.kt.mustache │ ├── AndroidManifest_ActivityPartial.xml.mustache │ ├── ApplicationComponent_ActivityPartial.kt.mustache │ ├── ApplicationComponent_ImportPartial.kt.mustache │ ├── Fragment.kt.mustache │ ├── ViewModel.kt.mustache │ ├── ViewModelFactoryModule_ImportPartial.kt.mustache │ ├── ViewModelFactoryModule_ViewModel.kt.mustache │ ├── ViewModelTests.kt.mustache │ ├── activity.xml.mustache │ ├── content.xml.mustache │ └── fragment.xml.mustache ├── build.gradle.kts ├── distribution └── keys │ ├── debug.keystore │ ├── generateKey.sh │ ├── sample.gradle │ └── sample.jks ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renamePackage.py ├── settings.gradle.kts └── testRenamePackage.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{kt,kts}] 4 | # This property IS supported and is used to ignore ktlint errors for Composable function names. 5 | # Please don't remove it. 6 | ktlint_function_naming_ignore_when_annotated_with=Composable 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. iPhone6] 28 | - OS: [e.g. iOS8.1] 29 | - Browser [e.g. stock browser, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | 35 | Acceptance Criteria (to be filled out by the designers/developers): 36 | - Criteria that must be met for this to be complete 37 | - Include anything of note that should be considered 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | 22 | Acceptance Criteria (to be filled out by the designers/developers): 23 | - Criteria that must be met for this to be complete 24 | - Include anything of note that should be considered 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | Your description here. 3 | 4 | ## Added 5 | - 6 | 7 | ## Changed 8 | - 9 | 10 | ## Removed 11 | - 12 | 13 | ## Is there test coverage for your changes? 14 | Yes/No 15 | 16 | ## Screenshots 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | local.properties 3 | .DS_Store 4 | build/ 5 | *.iml 6 | .idea/ 7 | *.swp 8 | com_crashlytics_export_strings.xml 9 | crashlytics-build.properties 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Atomic Robot, LLC 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Project Setup 2 | 3 | ### Setting up a new project 4 | - `git clone git@github.com:atomicrobot/Carbon-Android.git` 5 | - `cd Carbon-Android` 6 | - Download Python if needed https://www.python.org/downloads/ 7 | - `python renamePackage.py com.demo.mobile` 8 | - (optional) Rename the project directory from `Carbon-Android` to something meaningful. 9 | - (optional) Add a git remote to the project (a clean repository has been setup for you). 10 | - (optional) Adding a new activity: `./gradlew app:screen -Ppackage=com.demo.mobile -Pscreen=SignIn` 11 | 12 | ### First Run 13 | - When you open up a project for the first time, if you get a "Please specify Android SDK" message when trying to run the app then you need to run a Gradle Sync from Android Studio. 14 | 15 | ### Crashlytics 16 | To register an app to an organization you will need to go to the Crashlytics web dashboard, go to 17 | the organization settings, and then get the keys and secrets it displays. 18 | - Update `app/src/main/AndroidManifest.xml` with the organization api key. 19 | 20 | ### Signing configs 21 | *DO NOT* use the demo keystore in your apps. 22 | 23 | - Note: If you are creating signing keys, consider setting up Google Play App Signing (https://developer.android.com/studio/publish/app-signing.html#google-play-app-signing) 24 | - Run `distribution/keys/generateKey.sh release` and update `app/build.gradle` signing configs appropriately. 25 | 26 | ## Quality 27 | 28 | ### Automated Checks 29 | - Unit tests - `./gradlew allChecks` 30 | 31 | ## Gradle and plugins 32 | 33 | To see what the dependency tree currently looks like: 34 | - `./gradlew app:dependencies` 35 | 36 | ## Continuous Integration Setup 37 | 38 | These are written in the context of a TeamCity CI setup. 39 | 40 | ## Jacoco Test Report 41 | - `./gradlew clean jacocoTestReport` 42 | - To view report go to /build/reports/jacoco/jacocoTestReport/html/index.html 43 | 44 | ### Gradle tasks 45 | This will pull in the current Git SHA and auto incrementing build number as part of the build. 46 | 47 | `continuousIntegration -Pfingerprint=%build.vcs.number% -PbuildNumber=%build.counter% -PdisablePreDex` 48 | 49 | for local testing: 50 | 51 | `./gradlew continuousIntegration -Pfingerprint=DEVTEST -PbuildNumber=222 -PdisablePreDex` 52 | 53 | Also make sure the CI server is set to use the Gradle wrapper. 54 | 55 | ### Artifact Paths 56 | ``` 57 | app/build/outputs/apk/**/*-release.apk => apks 58 | app/build/outputs/mapping/**/release/mapping.txt => proguard 59 | 60 | app/build/reports/lint-results.html => quality/lint 61 | app/build/reports/pmd/ => quality/pmd 62 | app/build/reports/checkstyle/ => quality/checkstyle 63 | 64 | app/build/reports/tests/testDevDebugUnitTest => quality/tests 65 | app/build/reports/androidTests/connected/flavors/DEV => quality/androidTests 66 | app/build/reports/coverage/dev/debug/ => quality/coverage 67 | ``` 68 | 69 | ### Project Reports 70 | - "Lint" with a start page of `quality/lint/index.html` 71 | - "PMD" with a start page of `quality/pmd/pmd.html` 72 | - "Unit Tests" with a start page of `quality/tests/index.html` 73 | - "Integration Tests" with a start page of `quality/integrationTests/index.html` 74 | 75 | ### Supported Android Versions 76 | The Carbon app will only support those Android versions that Google Security continues to support: 77 | https://endoflife.date/android 78 | 79 | As of July 15, 2022, the latest Android version that's supported is Android 10. Therefore the minSDK 80 | is set to `29` 81 | 82 | License 83 | ======= 84 | 85 | Copyright 2014-2019 Atomic Robot, LLC 86 | 87 | Licensed under the Apache License, Version 2.0 (the "License"); 88 | you may not use this file except in compliance with the License. 89 | You may obtain a copy of the License at 90 | 91 | http://www.apache.org/licenses/LICENSE-2.0 92 | 93 | Unless required by applicable law or agreed to in writing, software 94 | distributed under the License is distributed on an "AS IS" BASIS, 95 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 96 | See the License for the specific language governing permissions and 97 | limitations under the License. 98 | -------------------------------------------------------------------------------- /app/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Keep SafeParcelable value, needed for reflection. This is required to support backwards 2 | # compatibility of some classes. 3 | -keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { 4 | public static final *** NULL; 5 | } 6 | 7 | # Kotlin Parcelize will throw Proguard warnings without this 8 | -dontwarn kotlin.internal.annotations.AvoidUninitializedObjectCopyingCheck 9 | 10 | # OkHttp3 11 | # A resource is loaded with a relative path so the package of this class must be preserved. 12 | -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase 13 | 14 | -dontwarn retrofit2.** 15 | -dontwarn okhttp3.** 16 | -dontwarn okio.** 17 | -dontwarn javax.annotation.** 18 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atomicrobot/carbon/EspressoMatchers.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import android.view.View 4 | import android.widget.TextView 5 | import androidx.test.espresso.matcher.BoundedMatcher 6 | import org.hamcrest.BaseMatcher 7 | import org.hamcrest.Description 8 | import org.hamcrest.Matcher 9 | import java.util.regex.Pattern 10 | 11 | object EspressoMatchers { 12 | fun regex(regex: String): Matcher { 13 | return object : BaseMatcher() { 14 | override fun matches(item: Any): Boolean { 15 | val pattern = Pattern.compile(regex) 16 | return pattern.matcher(item.toString()).matches() 17 | } 18 | 19 | override fun describeTo(description: Description) { 20 | description.appendText("regex '$regex'") 21 | } 22 | } 23 | } 24 | 25 | fun withFontSize(expectedSize: Float): Matcher { 26 | return object : BoundedMatcher(View::class.java) { 27 | override fun matchesSafely(target: View): Boolean { 28 | if (target !is TextView) { 29 | return false 30 | } 31 | val pixels = target.textSize 32 | val actualSize = pixels / target.getResources().displayMetrics.scaledDensity 33 | return actualSize.compareTo(expectedSize) == 0 34 | } 35 | 36 | override fun describeTo(description: Description) { 37 | description.appendText("with fontSize: ") 38 | description.appendValue(expectedSize) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atomicrobot/carbon/RecyclerViewMatcher.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import android.content.res.Resources 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | import org.hamcrest.Description 7 | import org.hamcrest.Matcher 8 | import org.hamcrest.TypeSafeMatcher 9 | 10 | /** 11 | * See: https://spin.atomicobject.com/2016/04/15/espresso-testing-recyclerviews/ 12 | */ 13 | fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher { 14 | return RecyclerViewMatcher(recyclerViewId) 15 | } 16 | 17 | class RecyclerViewMatcher(private val recyclerViewId: Int) { 18 | fun atPosition(position: Int): Matcher { 19 | return atPositionOnView(position, -1) 20 | } 21 | 22 | fun atPositionOnView( 23 | position: Int, 24 | targetViewId: Int, 25 | ): Matcher { 26 | return object : TypeSafeMatcher() { 27 | internal var resources: Resources? = null 28 | internal var childView: View? = null 29 | 30 | override fun describeTo(description: Description) { 31 | var idDescription = Integer.toString(recyclerViewId) 32 | if (this.resources != null) { 33 | try { 34 | idDescription = this.resources!!.getResourceName(recyclerViewId) 35 | } catch (var4: Resources.NotFoundException) { 36 | idDescription = String.format("%s (resource name not found)", recyclerViewId) 37 | } 38 | } 39 | 40 | description.appendText("RecyclerView with id: $idDescription at position: $position") 41 | } 42 | 43 | public override fun matchesSafely(view: View): Boolean { 44 | this.resources = view.resources 45 | 46 | if (childView == null) { 47 | val recyclerView = view.rootView.findViewById(recyclerViewId) as RecyclerView 48 | if (recyclerView.id == recyclerViewId) { 49 | val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) 50 | if (viewHolder != null) { 51 | childView = viewHolder.itemView 52 | } 53 | } else { 54 | return false 55 | } 56 | } 57 | 58 | return if (targetViewId == -1) { 59 | view === childView 60 | } else { 61 | val targetView = childView?.findViewById(targetViewId) 62 | view === targetView 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atomicrobot/carbon/TestMainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import com.atomicrobot.carbon.app.MainApplication 4 | 5 | class TestMainApplication : MainApplication() { 6 | override fun initializeApplication() { 7 | // Don't initialize the application 8 | } 9 | 10 | override fun isTesting() = true 11 | } 12 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atomicrobot/carbon/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import com.atomicrobot.carbon.app.MainApplication 5 | 6 | object TestUtils { 7 | fun getAppUnderTest(): MainApplication { 8 | val instrumentation = InstrumentationRegistry.getInstrumentation() 9 | val targetContext = instrumentation.targetContext 10 | return targetContext.applicationContext as MainApplication 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atomicrobot/carbon/ui/deeplink/DeepLinkEspressoTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.deeplink 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import org.junit.runner.RunWith 5 | 6 | @RunWith(AndroidJUnit4::class) 7 | class DeepLinkEspressoTest { 8 | // private lateinit var scenario: ActivityScenario 9 | // 10 | // @Test 11 | // fun testValidDeepLinkIntent() { 12 | // val uri = "https://www.atomicrobot.com/carbon-android/path1" 13 | // val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) 14 | // 15 | // scenario = ActivityScenario.launch(intent) 16 | // onView(withId(R.id.deep_link_text)).check(matches(isDisplayed())) 17 | // } 18 | // 19 | // @Test 20 | // fun testInvalidDeepLinkIntent() { 21 | // // Pass in invalid deeplink path. Should then be navigated to main fragment screen 22 | // val uri = "https://www.atomicrobot.com/carbon-android/path3" 23 | // val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) 24 | // 25 | // scenario = ActivityScenario.launch(intent) 26 | // 27 | // // Deep link text should not exist because uri was not valid 28 | // onView(withId(R.id.deep_link_text)).check(doesNotExist()) 29 | // 30 | // // Check to see if Main Fragment views are displaying 31 | // onView(withId(R.id.appbar)).check(matches(isDisplayed())) 32 | // onView(withId(R.id.header)).check(matches(isDisplayed())) 33 | // } 34 | // 35 | // @Test 36 | // fun testDeepLinkIntentWithTextColorValue() { 37 | // val textColor = "blue" 38 | // val uri = "https://www.atomicrobot.com/carbon-android/path1?textColor=$textColor" 39 | // val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) 40 | // 41 | // scenario = ActivityScenario.launch(intent) 42 | // onView(withId(R.id.deep_link_text)).check(matches(hasTextColor(R.color.blue))) 43 | // } 44 | // 45 | // @Test 46 | // fun testDeepLinkIntentWithTextSizeValue() { 47 | // val textSize = 22f 48 | // val uri = "https://www.atomicrobot.com/carbon-android/path1?textSize=$textSize" 49 | // val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) 50 | // 51 | // scenario = ActivityScenario.launch(intent) 52 | // 53 | // onView(withId(R.id.deep_link_text)).check(matches(withFontSize(textSize))) 54 | // } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atomicrobot/carbon/ui/main/StartActivityEspressoTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.main 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import org.hamcrest.Matchers.not 5 | import org.junit.runner.RunWith 6 | 7 | @RunWith(AndroidJUnit4::class) 8 | class StartActivityEspressoTest { 9 | // private lateinit var scenario: ActivityScenario 10 | // 11 | // // @get:Rule val activityRule = ActivityScenarioRule(StartActivity::class.java) 12 | // 13 | // private lateinit var gitHubInteractor: GitHubInteractor 14 | // 15 | // @Before 16 | // fun setup() { 17 | // gitHubInteractor = Mockito.mock(GitHubInteractor::class.java) 18 | // loadKoinModules( 19 | // module { 20 | // gitHubInteractor 21 | // } 22 | // ) 23 | // } 24 | // 25 | // @After 26 | // fun cleanUp() { 27 | // stopKoin() 28 | // scenario.close() 29 | // } 30 | // 31 | // @Test 32 | // fun testBuildFingerprint() { 33 | // whenever(gitHubInteractor.loadCommits(any())).thenReturn(Observable.empty()) 34 | // scenario = ActivityScenario.launch(StartActivity::class.java) 35 | // onView(withId(R.id.fingerprint)).check(matches(withText(regex("Fingerprint: .+")))) 36 | // } 37 | // 38 | // @Test 39 | // fun testFetchCommitsEnabledState() { 40 | // val response = LoadCommitsResponse( 41 | // LoadCommitsRequest("username", "repository"), 42 | // emptyList() 43 | // ) 44 | // whenever(gitHubInteractor.loadCommits(any())).thenReturn(Observable.just(response)) 45 | // 46 | // scenario = ActivityScenario.launch(StartActivity::class.java) 47 | // 48 | // Thread.sleep(5 * 1000L) 49 | // InstrumentationRegistry.getInstrumentation().waitForIdleSync() 50 | // 51 | // onView(withId(R.id.username)).perform(clearText()) 52 | // onView(withId(R.id.fetch_commits)).check(matches(not(isEnabled()))) 53 | // 54 | // onView(withId(R.id.username)).perform(typeText("username")) 55 | // onView(withId(R.id.fetch_commits)).check(matches(isEnabled())) 56 | // 57 | // onView(withId(R.id.repository)).perform(clearText()) 58 | // onView(withId(R.id.fetch_commits)).check(matches(not(isEnabled()))) 59 | // } 60 | // 61 | // @Test 62 | // fun testFetchAndDisplayCommits() { 63 | // val response = buildMockLoadCommitsResponse() 64 | // whenever(gitHubInteractor.loadCommits(any())).thenReturn(response) 65 | // 66 | // scenario = ActivityScenario.launch(StartActivity::class.java) 67 | // 68 | // Thread.sleep(5 * 1000L) 69 | // InstrumentationRegistry.getInstrumentation().waitForIdleSync() 70 | // 71 | // closeSoftKeyboard() 72 | // 73 | // // Check recycler view's 0th position and confirm text does not equal default test values 74 | // onView( 75 | // withRecyclerView(R.id.commits) 76 | // .atPositionOnView(0, R.id.author) 77 | // ) 78 | // .check(matches(not(withText("Author: Test author")))) 79 | // onView( 80 | // withRecyclerView(R.id.commits) 81 | // .atPositionOnView(0, R.id.message) 82 | // ) 83 | // .check(matches(not(withText("Test commit message")))) 84 | // } 85 | // 86 | // private fun buildMockLoadCommitsResponse(): Observable { 87 | // val request = LoadCommitsRequest("madebyatomicrobot", "android-starter-project") 88 | // val commit = Commit(CommitDetails("Test commit message", Author("Test author"))) 89 | // return Observable.just(LoadCommitsResponse(request, listOf(commit))) 90 | // } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/debug/java/com/atomicrobot/carbon/app/MainApplicationInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.app.Application.ActivityLifecycleCallbacks 6 | import android.os.Bundle 7 | import com.atomicrobot.carbon.ui.RiseAndShine 8 | import timber.log.Timber 9 | 10 | /** 11 | * Specific to the debug variant. 12 | */ 13 | class MainApplicationInitializer(application: Application) : 14 | BaseApplicationInitializer(application, Timber.DebugTree()) { 15 | override fun initialize() { 16 | super.initialize() 17 | application.registerActivityLifecycleCallbacks( 18 | object : ActivityLifecycleCallbacks { 19 | override fun onActivityCreated( 20 | activity: Activity, 21 | savedInstanceState: Bundle?, 22 | ) { 23 | RiseAndShine.riseAndShine(activity) 24 | } 25 | 26 | override fun onActivityStarted(activity: Activity) { 27 | } 28 | 29 | override fun onActivityResumed(activity: Activity) { 30 | } 31 | 32 | override fun onActivityPaused(activity: Activity) { 33 | } 34 | 35 | override fun onActivityStopped(activity: Activity) { 36 | } 37 | 38 | override fun onActivitySaveInstanceState( 39 | activity: Activity, 40 | outState: Bundle, 41 | ) { 42 | } 43 | 44 | override fun onActivityDestroyed(activity: Activity) { 45 | } 46 | }, 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/debug/java/com/atomicrobot/carbon/app/RobolectricApplication.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Application 5 | 6 | @SuppressLint("Registered") 7 | class RobolectricApplication : Application() 8 | -------------------------------------------------------------------------------- /app/src/debug/java/com/atomicrobot/carbon/modules/CrashReporterModule.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.modules 2 | 3 | import com.atomicrobot.carbon.monitoring.LoggingOnlyCrashReporter 4 | import org.koin.dsl.module 5 | 6 | val crashReporterModule = 7 | module { 8 | single { 9 | LoggingOnlyCrashReporter() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/debug/java/com/atomicrobot/carbon/ui/RiseAndShine.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui 2 | 3 | import android.app.Activity 4 | import android.app.KeyguardManager 5 | import android.content.Context 6 | import android.os.PowerManager 7 | import android.view.WindowManager 8 | 9 | /** 10 | * Inspired from https://gist.github.com/JakeWharton/f50f3b4d87e57d8e96e9 11 | */ 12 | @Suppress("DEPRECATION") 13 | object RiseAndShine { 14 | private val LOCK_FLAGS = PowerManager.FULL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.ON_AFTER_RELEASE 15 | 16 | fun riseAndShine(activity: Activity) { 17 | val keyguardManager = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager 18 | val keyguardLock = keyguardManager.newKeyguardLock(activity.localClassName) 19 | keyguardLock.disableKeyguard() 20 | 21 | activity.window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED) 22 | 23 | val powerManager = activity.getSystemService(Context.POWER_SERVICE) as PowerManager 24 | val lock = powerManager.newWakeLock(LOCK_FLAGS, "carbon:riseandshine") 25 | 26 | lock.acquire(1000) 27 | lock.release() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/debug/res/drawable-anydpi/ic_bell_notification.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/debug/res/drawable-hdpi/ic_bell_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/debug/res/drawable-hdpi/ic_bell_notification.png -------------------------------------------------------------------------------- /app/src/debug/res/drawable-mdpi/ic_bell_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/debug/res/drawable-mdpi/ic_bell_notification.png -------------------------------------------------------------------------------- /app/src/debug/res/drawable-xhdpi/ic_bell_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/debug/res/drawable-xhdpi/ic_bell_notification.png -------------------------------------------------------------------------------- /app/src/debug/res/drawable-xxhdpi/ic_bell_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/debug/res/drawable-xxhdpi/ic_bell_notification.png -------------------------------------------------------------------------------- /app/src/dev/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/dev/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "542450510948", 4 | "project_id": "carbon-622cf", 5 | "storage_bucket": "carbon-622cf.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:542450510948:android:24f99c77ea690b74ac8371", 11 | "android_client_info": { 12 | "package_name": "com.atomicrobot.carbon" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "542450510948-mpqi42i1dnsk88j6t9qgv1e1lnnsg3hc.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyCNZr1_ram8K4V0r32-SsRPNrQjOyp0SC0" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "542450510948-mpqi42i1dnsk88j6t9qgv1e1lnnsg3hc.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | }, 37 | { 38 | "client_info": { 39 | "mobilesdk_app_id": "1:542450510948:android:46f00365f3f342b1ac8371", 40 | "android_client_info": { 41 | "package_name": "com.atomicrobot.carbon.dev" 42 | } 43 | }, 44 | "oauth_client": [ 45 | { 46 | "client_id": "542450510948-mpqi42i1dnsk88j6t9qgv1e1lnnsg3hc.apps.googleusercontent.com", 47 | "client_type": 3 48 | } 49 | ], 50 | "api_key": [ 51 | { 52 | "current_key": "AIzaSyCNZr1_ram8K4V0r32-SsRPNrQjOyp0SC0" 53 | } 54 | ], 55 | "services": { 56 | "appinvite_service": { 57 | "other_platform_oauth_client": [ 58 | { 59 | "client_id": "542450510948-mpqi42i1dnsk88j6t9qgv1e1lnnsg3hc.apps.googleusercontent.com", 60 | "client_type": 3 61 | } 62 | ] 63 | } 64 | } 65 | } 66 | ], 67 | "configuration_version": "1" 68 | } -------------------------------------------------------------------------------- /app/src/dev/java/com/atomicrobot/carbon/app/SSLDevelopmentHelper.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.annotation.SuppressLint 4 | import okhttp3.OkHttpClient 5 | import java.security.SecureRandom 6 | import java.security.cert.CertificateException 7 | import java.security.cert.X509Certificate 8 | import javax.net.ssl.HostnameVerifier 9 | import javax.net.ssl.SSLContext 10 | import javax.net.ssl.TrustManager 11 | import javax.net.ssl.X509TrustManager 12 | 13 | object SSLDevelopmentHelper { 14 | fun applyTrustAllSettings(builder: OkHttpClient.Builder): OkHttpClient.Builder { 15 | return builder.apply { 16 | hostnameVerifier(buildTrustAllHostnameVerifier()) 17 | 18 | val trustManager = buildTrustAllTrustManager() 19 | val sslContext = buildTrustAllSSLContext() 20 | sslContext.init(null, arrayOf(trustManager), SecureRandom()) 21 | sslSocketFactory(sslContext.socketFactory, trustManager) 22 | } 23 | } 24 | 25 | private fun buildTrustAllHostnameVerifier(): HostnameVerifier { 26 | return HostnameVerifier { _, _ -> true } 27 | } 28 | 29 | private fun buildTrustAllSSLContext(): SSLContext { 30 | // Create a trust manager that does not validate certificate chains to 31 | // avoid "Trust anchor for certification path not found" exception 32 | val trustAllCerts = arrayOf(buildTrustAllTrustManager()) 33 | 34 | try { 35 | val sc = SSLContext.getInstance("TLS") // NON-NLS 36 | sc.init(null, trustAllCerts, SecureRandom()) 37 | return sc 38 | } catch (ex: Exception) { 39 | throw RuntimeException(ex) 40 | } 41 | } 42 | 43 | private fun buildTrustAllTrustManager(): X509TrustManager { 44 | return object : X509TrustManager { 45 | override fun getAcceptedIssuers(): Array { 46 | return arrayOf() 47 | } 48 | 49 | @SuppressLint("TrustAllX509TrustManager") 50 | @Throws(CertificateException::class) 51 | override fun checkClientTrusted( 52 | chain: Array, 53 | authType: String, 54 | ) { 55 | } 56 | 57 | @SuppressLint("TrustAllX509TrustManager") 58 | @Throws(CertificateException::class) 59 | override fun checkServerTrusted( 60 | chain: Array, 61 | authType: String, 62 | ) { 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/dev/java/com/atomicrobot/carbon/app/VariantModule.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import okhttp3.OkHttpClient 4 | import org.koin.dsl.module 5 | 6 | val variantModule = 7 | module { 8 | single { 9 | DevSecurityModifier( 10 | settings = get(), 11 | ) as OkHttpSecurityModifier // Cast is needed - compiler lies 12 | } 13 | 14 | // viewModel { 15 | // DevSettingsViewModel( 16 | // app = androidApplication(), 17 | // settings = get() 18 | // ) 19 | // } 20 | } 21 | 22 | class DevSecurityModifier(val settings: Settings) : OkHttpSecurityModifier { 23 | override fun apply(builder: OkHttpClient.Builder) { 24 | if (settings.trustAllSSL) { 25 | SSLDevelopmentHelper.applyTrustAllSettings(builder) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/dev/java/com/atomicrobot/carbon/app/VariantSettings.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.content.Context 4 | import android.text.TextUtils 5 | import com.atomicrobot.carbon.R 6 | import com.atomicrobot.carbon.util.putOrClearPreference 7 | 8 | open class VariantSettings(private val context: Context) { 9 | private val preferences = context.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) 10 | 11 | var baseUrl: String 12 | get() = preferences.getString(PREF_BASE_URL, context.getString(R.string.default_base_url))!! 13 | set(baseUrl) = preferences.putOrClearPreference(PREF_BASE_URL, !TextUtils.isEmpty(baseUrl), baseUrl) 14 | 15 | var trustAllSSL: Boolean 16 | get() = preferences.getBoolean(PREF_TRUST_ALL_SSL, false) 17 | set(trustAllSSL) = preferences.putOrClearPreference(PREF_TRUST_ALL_SSL, trustAllSSL, trustAllSSL) 18 | 19 | companion object { 20 | private const val PREFS_SETTINGS = "settings" // NON-NLS 21 | 22 | private const val PREF_BASE_URL = "base_url" // NON-NLS 23 | private const val PREF_TRUST_ALL_SSL = "trust_all_ssl" // NON-NLS 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/dev/java/com/atomicrobot/carbon/ui/devsettings/DevSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.devsettings 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import com.atomicrobot.carbon.app.Settings 6 | 7 | class DevSettingsViewModel( 8 | private val app: Application, 9 | private val settings: Settings, 10 | ) : ViewModel() { 11 | fun setupViewModel() { 12 | baseUrl = settings.baseUrl 13 | trustAllSSL = settings.trustAllSSL 14 | } 15 | 16 | var baseUrl: String = "" 17 | 18 | var trustAllSSL: Boolean = false 19 | 20 | fun saveSettings() { 21 | settings.baseUrl = baseUrl 22 | settings.trustAllSSL = trustAllSSL 23 | } 24 | 25 | companion object { 26 | private const val STATE_KEY = "DevSettingsViewModelState" // NON-NLS 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/dev/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | My App Dev Settings 4 | Base URL 5 | Trust all SSL connections 6 | Save settings 7 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 | 64 | 67 | 68 | 71 | 72 | 73 | 74 | 75 | 76 | 78 | 81 | 83 | 86 | 87 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/Mockable.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | /** 4 | * See https://stackoverflow.com/a/44284452/541313 5 | * 6 | * I'm not happy about it either. 7 | */ 8 | @Target(AnnotationTarget.CLASS) 9 | annotation class Mockable 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/StartActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 10 | import com.atomicrobot.carbon.ui.navigation.MainNavigation 11 | import com.atomicrobot.carbon.ui.splash.SplashViewModel 12 | import com.atomicrobot.carbon.ui.theme.CarbonAndroidTheme 13 | import com.atomicrobot.carbon.util.LocalActivity 14 | import org.koin.androidx.viewmodel.ext.android.viewModel 15 | import timber.log.Timber 16 | 17 | class StartActivity : ComponentActivity() { 18 | private val splashViewModel: SplashViewModel by viewModel() 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | installSplashScreen() 22 | super.onCreate(savedInstanceState) 23 | handleIntent(intent) 24 | 25 | setContent { 26 | CarbonAndroidTheme { 27 | // Wrap the composable in a LocalActivity provider so our composable 'environment' 28 | // has access to Activity context/scope which is required for requesting permissions 29 | CompositionLocalProvider(LocalActivity provides this) { 30 | MainNavigation() 31 | } 32 | } 33 | } 34 | } 35 | 36 | private fun handleIntent(intent: Intent): Boolean { 37 | val appLinkAction = intent.action 38 | val appLinkData: Uri? = intent.data 39 | return if (Intent.ACTION_VIEW == appLinkAction) { 40 | appLinkData?.encodedPath?.also { 41 | splashViewModel.setDeepLinkUri(appLinkData) 42 | splashViewModel.setDeepLinkPath(appLinkData.encodedPath) 43 | 44 | Timber.d("appLinkData = $appLinkData") 45 | Timber.d("appLinkData.encodedPath = ${appLinkData.encodedPath}") 46 | Timber.d("appLinkData.query = ${appLinkData.query}") 47 | Timber.d("appLinkData.encodedQuery = ${appLinkData.encodedQuery}") 48 | Timber.d("appLinkData.queryParameterNames = ${appLinkData.queryParameterNames}") 49 | } 50 | true 51 | } else { 52 | false 53 | } 54 | } 55 | 56 | companion object { 57 | const val MAIN_PAGE = "mainScreen" 58 | const val DEEP_LINK_PATH_1 = "deepLinkPath1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/app/BaseApplicationInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import com.google.android.gms.common.GoogleApiAvailability 6 | import com.google.android.gms.security.ProviderInstaller 7 | import com.google.android.gms.security.ProviderInstaller.ProviderInstallListener 8 | import timber.log.Timber 9 | import timber.log.Timber.Tree 10 | 11 | abstract class BaseApplicationInitializer( 12 | protected val application: Application, 13 | private val logger: Tree, 14 | ) { 15 | open fun initialize() { 16 | Timber.plant(logger) 17 | 18 | upgradeSecurityProvider() 19 | } 20 | 21 | private fun upgradeSecurityProvider() { 22 | ProviderInstaller.installIfNeededAsync( 23 | application, 24 | object : ProviderInstallListener { 25 | override fun onProviderInstalled() { 26 | } 27 | 28 | override fun onProviderInstallFailed( 29 | errorCode: Int, 30 | recoveryIntent: Intent?, 31 | ) { 32 | GoogleApiAvailability.getInstance().showErrorNotification(application, errorCode) 33 | } 34 | }, 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/app/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.app.Application 4 | import com.atomicrobot.carbon.modules.crashReporterModule 5 | import com.atomicrobot.carbon.util.AppLogger 6 | import org.koin.android.ext.koin.androidContext 7 | import org.koin.core.context.GlobalContext 8 | import org.koin.core.context.startKoin 9 | 10 | open class MainApplication : Application() { 11 | private lateinit var initializer: MainApplicationInitializer 12 | 13 | override fun onCreate() { 14 | super.onCreate() 15 | // This check is for Robolectric tests that run in parallel so Koin gets setup correctly 16 | if (GlobalContext.getOrNull() == null) { 17 | startKoin { 18 | androidContext(this@MainApplication) 19 | 20 | AppLogger() 21 | 22 | val mainModules = Modules() 23 | modules( 24 | listOf( 25 | mainModules.appModules, 26 | mainModules.dataModules, 27 | mainModules.viewModelModules, 28 | variantModule, 29 | crashReporterModule, 30 | ), 31 | ) 32 | } 33 | } 34 | 35 | initializer = MainApplicationInitializer(this) 36 | 37 | initializeApplication() 38 | } 39 | 40 | /** 41 | * For Espresso tests we *do not* want to setupViewModel the full application and instead will 42 | * favor mocking out non-ui dependencies. This should be overridden to a no-op if initialization 43 | * should not occur. 44 | */ 45 | protected open fun initializeApplication() { 46 | initializer.initialize() 47 | } 48 | 49 | open fun isTesting() = false 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/app/Settings.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.content.Context 4 | 5 | class Settings(context: Context) : VariantSettings(context) 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/api/github/DetailedGitHubApiService.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import com.atomicrobot.carbon.data.api.github.model.DetailedCommit 4 | import retrofit2.Response 5 | import retrofit2.http.GET 6 | import retrofit2.http.Path 7 | 8 | interface DetailedGitHubApiService { 9 | @GET("repos/{user}/{repository}/commits/{sha}") 10 | suspend fun detailedCommit( 11 | @Path("user") user: String, 12 | @Path("repository") repository: String, 13 | @Path("sha") sha: String, 14 | ): Response 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/api/github/GitHubApiService.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import com.atomicrobot.carbon.data.api.github.model.Commit 4 | import retrofit2.Response 5 | import retrofit2.http.GET 6 | import retrofit2.http.Path 7 | 8 | interface GitHubApiService { 9 | @GET("repos/{user}/{repository}/commits") 10 | suspend fun listCommits( 11 | @Path("user") user: String, 12 | @Path("repository") repository: String, 13 | ): Response> 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/api/github/GitHubInteractor.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import android.content.Context 4 | import androidx.annotation.VisibleForTesting 5 | import com.atomicrobot.carbon.Mockable 6 | import com.atomicrobot.carbon.R 7 | import com.atomicrobot.carbon.data.api.github.model.Commit 8 | import com.atomicrobot.carbon.data.api.github.model.DetailedCommit 9 | import retrofit2.Response 10 | import timber.log.Timber 11 | 12 | @Mockable 13 | class GitHubInteractor( 14 | private val context: Context, 15 | private val api: GitHubApiService, 16 | private val api2: DetailedGitHubApiService, 17 | ) { 18 | class LoadCommitsRequest(val user: String, val repository: String) 19 | 20 | class LoadCommitsResponse(val request: LoadCommitsRequest, val commits: List) 21 | 22 | class LoadDetailedCommitRequest(val user: String, val repository: String, val sha: String) 23 | 24 | class LoadDetailedCommitResponse(val request: LoadDetailedCommitRequest, val commit: DetailedCommit) 25 | 26 | suspend fun loadCommits(request: LoadCommitsRequest): LoadCommitsResponse { 27 | val response = 28 | checkResponse( 29 | api.listCommits(request.user, request.repository), 30 | context.getString(R.string.error_get_commits_error), 31 | ) 32 | val commits: List = response.body() ?: emptyList() 33 | return LoadCommitsResponse(request, commits) 34 | } 35 | 36 | suspend fun loadDetailedCommit(request: LoadDetailedCommitRequest): LoadDetailedCommitResponse { 37 | val detailedResponse = 38 | checkResponse( 39 | api2.detailedCommit(request.user, request.repository, request.sha), 40 | context.getString(R.string.error_get_detailed_commit_error), 41 | ) 42 | val detailedCommit: DetailedCommit = detailedResponse.body() ?: emptyList()[0] 43 | return LoadDetailedCommitResponse(request, detailedCommit) 44 | } 45 | 46 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) 47 | fun checkResponse( 48 | response: Response, 49 | message: String, 50 | ): Response { 51 | return when { 52 | response.isSuccessful -> response 53 | else -> { 54 | Timber.e(response.errorBody()?.string() ?: message) 55 | throw IllegalStateException(message) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/api/github/model/Commit.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github.model 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Author( 8 | @Json(name = "name") val name: String, 9 | @Json(name = "email") val email: String, 10 | @Json(name = "date") val date: String, 11 | ) 12 | 13 | @JsonClass(generateAdapter = true) 14 | data class CommitDetails( 15 | @Json(name = "message") val message: String, 16 | @Json(name = "author") val author: Author, 17 | ) 18 | 19 | @JsonClass(generateAdapter = true) 20 | data class Commit( 21 | @Json(name = "commit") val commit: CommitDetails, 22 | @Json(name = "sha") val sha: String, 23 | ) { 24 | val commitMessage: String 25 | get() = commit.message 26 | 27 | val author: String 28 | get() = commit.author.name 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/api/github/model/DetailedCommit.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github.model 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Tree( 8 | @Json(name = "url") val url: String, 9 | ) 10 | 11 | @JsonClass(generateAdapter = true) 12 | data class Verification( 13 | @Json(name = "verified") val verified: Boolean, 14 | ) 15 | 16 | @JsonClass(generateAdapter = true) 17 | data class DetailedCommitDetails( 18 | @Json(name = "message") val message: String, 19 | @Json(name = "author") val author: Author, 20 | @Json(name = "tree") val tree: Tree, 21 | @Json(name = "verification") val verification: Verification, 22 | ) 23 | 24 | @JsonClass(generateAdapter = true) 25 | data class DetailedCommit( 26 | @Json(name = "commit") val detailedCommit: DetailedCommitDetails, 27 | ) { 28 | val detailedCommitMessage: String 29 | get() = detailedCommit.message 30 | 31 | val detailedCommitAuthor: String 32 | get() = detailedCommit.author.name 33 | 34 | val detailedCommitTreeURL: String 35 | get() = detailedCommit.tree.url 36 | 37 | val detailedCommitVerified: Boolean 38 | get() = detailedCommit.verification.verified 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/SceneModel.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen 2 | 3 | import com.atomicrobot.carbon.data.lumen.dto.LumenScene 4 | import com.atomicrobot.carbon.data.lumen.dto.SceneAndLightsWithRoom 5 | 6 | data class SceneModel( 7 | val sceneId: Long = 0, 8 | val name: String = "", 9 | val active: Boolean = false, 10 | val favorite: Boolean = false, 11 | val duration: String = "", 12 | val roomId: Long = 0, 13 | val roomName: String = "", 14 | val lights: List = emptyList(), 15 | ) { 16 | constructor(scene: SceneAndLightsWithRoom) : this( 17 | sceneId = scene.sceneId, 18 | name = scene.sceneName, 19 | favorite = scene.favorite, 20 | duration = scene.duration, 21 | roomId = scene.roomId, 22 | roomName = scene.roomName, 23 | lights = scene.lights.map { it.lightId }, 24 | ) 25 | } 26 | 27 | fun SceneModel.toLumenScene(): LumenScene { 28 | return LumenScene( 29 | sceneId = this.sceneId, 30 | sceneName = this.name, 31 | containingRoomId = this.roomId, 32 | duration = this.duration, 33 | active = this.active, 34 | favorite = this.favorite, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dao/LightDao.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Update 8 | import com.atomicrobot.carbon.data.lumen.dto.LumenLight 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | @Dao 12 | interface LightDao { 13 | @Update 14 | suspend fun update(light: LumenLight): Int 15 | 16 | @Update 17 | suspend fun update(light: List): Int 18 | 19 | @Insert 20 | suspend fun insert(light: LumenLight): Long 21 | 22 | @Insert 23 | suspend fun insert(light: List): List 24 | 25 | @Delete 26 | suspend fun delete(light: LumenLight): Int 27 | 28 | @Delete 29 | suspend fun delete(light: List): Int 30 | 31 | @Query("SELECT * FROM LumenLight") 32 | fun getAllLights(): Flow> 33 | 34 | @Query( 35 | "SELECT lumenLight.* FROM lumenLight " + 36 | "INNER JOIN LumenSceneLightCrossRef sceneLight ON sceneLight.lightId = lumenLight.lightId " + 37 | "WHERE sceneLight.sceneId = :sceneId", 38 | ) 39 | fun getAllLightsForScene(sceneId: Long): Flow> 40 | 41 | @Query("SELECT * FROM LumenLight WHERE containingRoomId = :roomId") 42 | fun getAllLightsForRoom(roomId: Long): Flow> 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dao/RoomDao.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import androidx.room.Update 9 | import com.atomicrobot.carbon.data.lumen.dto.LumenRoom 10 | import com.atomicrobot.carbon.data.lumen.dto.RoomNameAndId 11 | import kotlinx.coroutines.flow.Flow 12 | 13 | @Dao 14 | interface RoomDao { 15 | @Update 16 | suspend fun update(room: LumenRoom): Int 17 | 18 | @Update 19 | suspend fun update(room: List): Int 20 | 21 | @Insert 22 | suspend fun insert(room: LumenRoom): Long 23 | 24 | @Insert 25 | suspend fun insert(room: List): List 26 | 27 | @Delete 28 | suspend fun delete(room: LumenRoom): Int 29 | 30 | @Delete 31 | suspend fun delete(room: List): Int 32 | 33 | @Transaction 34 | @Query("SELECT * FROM LumenRoom") 35 | suspend fun getRoomNamesAndIds(): List 36 | 37 | @Transaction 38 | @Query("SELECT * FROM LumenRoom") 39 | fun getRoomNamesAndIdsFlow(): Flow> 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dao/SceneDao.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import androidx.room.Update 9 | import com.atomicrobot.carbon.data.lumen.dto.LumenLight 10 | import com.atomicrobot.carbon.data.lumen.dto.LumenScene 11 | import com.atomicrobot.carbon.data.lumen.dto.LumenSceneLightCrossRef 12 | import com.atomicrobot.carbon.data.lumen.dto.SceneAndLightsWithRoom 13 | import com.atomicrobot.carbon.data.lumen.dto.SceneAndRoomName 14 | import kotlinx.coroutines.flow.Flow 15 | 16 | @Dao 17 | interface SceneDao { 18 | @Update 19 | suspend fun update(scene: LumenScene): Int 20 | 21 | @Update 22 | suspend fun update(scene: List): Int 23 | 24 | @Insert 25 | suspend fun insert(scene: LumenScene): Long 26 | 27 | @Insert 28 | suspend fun insert(scene: List): List 29 | 30 | @Delete 31 | suspend fun delete(scene: LumenScene): Int 32 | 33 | @Delete 34 | suspend fun delete(scene: List): Int 35 | 36 | @Query("DELETE FROM LumenScene WHERE sceneId = :sceneId") 37 | suspend fun delete(sceneId: Long): Int 38 | 39 | @Query("SELECT * FROM LumenScene") 40 | fun getScenes(): Flow> 41 | 42 | @Query("SELECT * FROM LumenScene WHERE sceneId = :sceneId") 43 | fun getScene(sceneId: Long): Flow 44 | 45 | @Query( 46 | "SELECT * FROM LumenScene as scene " + 47 | "INNER JOIN LumenSceneLightCrossRef crossRef ON crossRef.sceneId = scene.sceneId " + 48 | "INNER JOIN LumenLight light ON light.lightId = crossRef.lightId WHERE scene.sceneId = :sceneId", 49 | ) 50 | fun getSceneWithLights(sceneId: Long): Flow>> 51 | 52 | @Transaction 53 | @Query("SELECT * FROM LumenScene") 54 | fun getScenesWithRoom(): Flow> 55 | 56 | @Transaction 57 | @Query("SELECT * FROM LumenScene WHERE sceneId = :sceneId") 58 | suspend fun getSceneAndLightsWithRoom(sceneId: Long): SceneAndLightsWithRoom 59 | 60 | @Transaction 61 | @Query("SELECT * FROM LumenScene WHERE sceneId = :sceneId") 62 | fun getSceneAndLightsWithRoomFlow(sceneId: Long): Flow 63 | 64 | @Query("SELECT * FROM LumenSceneLightCrossRef WHERE sceneId = :sceneId") 65 | suspend fun getSceneLightReferences(sceneId: Long): List 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dao/SceneLightDao.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Update 7 | import com.atomicrobot.carbon.data.lumen.dto.LumenSceneLightCrossRef 8 | 9 | @Dao 10 | interface SceneLightDao { 11 | @Update 12 | suspend fun update(room: LumenSceneLightCrossRef): Int 13 | 14 | @Update 15 | suspend fun update(room: List): Int 16 | 17 | @Insert 18 | suspend fun insert(room: LumenSceneLightCrossRef): Long 19 | 20 | @Insert 21 | suspend fun insert(room: List): List 22 | 23 | @Delete 24 | suspend fun delete(room: LumenSceneLightCrossRef): Int 25 | 26 | @Delete 27 | suspend fun delete(room: List): Int 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dto/LumenLight.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dto 2 | 3 | import android.graphics.Color 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Entity 6 | import androidx.room.ForeignKey 7 | import androidx.room.PrimaryKey 8 | 9 | @Entity( 10 | foreignKeys = [ 11 | ForeignKey( 12 | entity = LumenRoom::class, 13 | parentColumns = arrayOf("roomId"), 14 | childColumns = arrayOf("containingRoomId"), 15 | ), 16 | ], 17 | ) 18 | data class LumenLight( 19 | @PrimaryKey(autoGenerate = true) val lightId: Long = 0L, 20 | val lightName: String, 21 | @ColumnInfo(index = true) 22 | val containingRoomId: Long = 0L, 23 | val type: LightType = LightType.WHITE, 24 | val color: Int = Color.WHITE, 25 | val brightness: Float = 1F, 26 | ) { 27 | val active: Boolean 28 | get() = brightness > 0.0F 29 | 30 | val colorTemperature: String 31 | get() = "Warm White" 32 | 33 | override fun toString(): String = lightName 34 | } 35 | 36 | enum class LightType { 37 | WHITE, 38 | WHITE_SMALL, 39 | COLOR, 40 | COLOR_SMALL, 41 | COLOR_STRIP, 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dto/LumenRoom.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dto 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class LumenRoom( 8 | @PrimaryKey(autoGenerate = true) val roomId: Long, 9 | val roomName: String, 10 | val location: String = "Home", 11 | ) { 12 | override fun toString(): String = roomName 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dto/LumenScene.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dto 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | import androidx.room.PrimaryKey 7 | 8 | @Entity( 9 | foreignKeys = [ 10 | ForeignKey( 11 | entity = LumenRoom::class, 12 | parentColumns = arrayOf("roomId"), 13 | childColumns = arrayOf("containingRoomId"), 14 | ), 15 | ], 16 | ) 17 | data class LumenScene( 18 | @PrimaryKey(autoGenerate = true) val sceneId: Long = 0L, 19 | val sceneName: String = "New Scene", 20 | @ColumnInfo(index = true) 21 | val containingRoomId: Long = 0L, 22 | val duration: String = "1 hour", 23 | val active: Boolean = false, 24 | val favorite: Boolean = false, 25 | ) { 26 | override fun toString(): String = sceneName 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/data/lumen/dto/SceneAndLight.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.lumen.dto 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.ForeignKey 7 | import androidx.room.ForeignKey.Companion.CASCADE 8 | import androidx.room.Ignore 9 | import androidx.room.Junction 10 | import androidx.room.PrimaryKey 11 | import androidx.room.Relation 12 | 13 | @Entity( 14 | foreignKeys = [ 15 | ForeignKey( 16 | entity = LumenScene::class, 17 | parentColumns = arrayOf("sceneId"), 18 | childColumns = arrayOf("sceneId"), 19 | onDelete = CASCADE, 20 | ), ForeignKey( 21 | entity = LumenLight::class, 22 | parentColumns = arrayOf("lightId"), 23 | childColumns = arrayOf("lightId"), 24 | onDelete = CASCADE, 25 | ), 26 | ], 27 | ) 28 | data class LumenSceneLightCrossRef( 29 | @PrimaryKey(autoGenerate = true) val id: Long = 0L, 30 | @ColumnInfo(index = true) 31 | val sceneId: Long, 32 | @ColumnInfo(index = true) 33 | val lightId: Long, 34 | ) 35 | 36 | data class RoomNameAndId( 37 | val roomId: Long = 0L, 38 | val roomName: String = "", 39 | ) { 40 | override fun toString(): String = roomName 41 | } 42 | 43 | data class SceneAndRoomName( 44 | @Embedded 45 | val scene: LumenScene, 46 | @Relation( 47 | parentColumn = "containingRoomId", 48 | entityColumn = "roomId", 49 | entity = LumenRoom::class, 50 | ) 51 | val room: RoomNameAndId, 52 | ) 53 | 54 | data class SceneAndLightsWithRoom( 55 | @Embedded 56 | val scene: LumenScene, 57 | @Relation( 58 | parentColumn = "sceneId", 59 | entityColumn = "lightId", 60 | associateBy = Junction(LumenSceneLightCrossRef::class), 61 | ) 62 | val lights: List, 63 | @Relation( 64 | parentColumn = "containingRoomId", 65 | entityColumn = "roomId", 66 | entity = LumenRoom::class, 67 | ) 68 | val room: RoomNameAndId, 69 | ) { 70 | @Ignore 71 | val sceneId = scene.sceneId 72 | 73 | @Ignore 74 | val sceneName = scene.sceneName 75 | 76 | @Ignore 77 | val favorite = scene.favorite 78 | 79 | @Ignore 80 | val duration = scene.duration 81 | 82 | @Ignore 83 | val roomId = room.roomId 84 | 85 | @Ignore 86 | val roomName = room.roomName 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/deeplink/DeepLinkInteractor.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.deeplink 2 | 3 | import android.graphics.Color 4 | import android.net.Uri 5 | import com.atomicrobot.carbon.StartActivity 6 | import com.atomicrobot.carbon.navigation.CarbonScreens 7 | import timber.log.Timber 8 | import java.lang.NumberFormatException 9 | 10 | class DeepLinkInteractor { 11 | private var deepLinkUri: Uri? = null 12 | private var deepLinkPath: String? = null 13 | 14 | fun setDeepLinkUri(uri: Uri?) { 15 | this.deepLinkUri = uri 16 | } 17 | 18 | fun setDeepLinkPath(path: String?) { 19 | this.deepLinkPath = path 20 | } 21 | 22 | fun getDeepLinkNavDestination(): String { 23 | deepLinkPath?.let { path -> 24 | when (path) { 25 | "/carbon-android" -> { 26 | Timber.d("default deep link received") 27 | return CarbonScreens.Home.route 28 | } 29 | "/carbon-android/path1" -> { 30 | Timber.d("path1 deep link received") 31 | return StartActivity.DEEP_LINK_PATH_1 32 | } 33 | else -> { 34 | Timber.e("Deep link path not recognized") 35 | return CarbonScreens.Home.route 36 | } 37 | } 38 | } 39 | return StartActivity.MAIN_PAGE 40 | } 41 | 42 | fun getDeepLinkTextColor(): Int { 43 | var color = Color.BLACK 44 | deepLinkUri?.let { uri -> 45 | val textColor = uri.getQueryParameter("textColor") 46 | if (!textColor.isNullOrEmpty()) { 47 | try { 48 | color = Color.parseColor(textColor) 49 | } catch (exception: IllegalArgumentException) { 50 | Timber.e("Unsupported value for color") 51 | } 52 | } 53 | } 54 | 55 | return color 56 | } 57 | 58 | fun getDeepLinkTextSize(): Float { 59 | var size = 30f 60 | deepLinkUri?.let { uri -> 61 | val textSize = uri.getQueryParameter("textSize") 62 | if (!textSize.isNullOrEmpty()) { 63 | try { 64 | size = textSize.toFloat() 65 | } catch (exception: NumberFormatException) { 66 | Timber.e("Unsupported value for size") 67 | } 68 | } 69 | } 70 | 71 | return size 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/monitoring/CrashReporter.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.monitoring 2 | 3 | interface CrashReporter { 4 | fun logMessage(message: String) 5 | 6 | fun logException( 7 | message: String, 8 | ex: Exception, 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/monitoring/CrashlyticsCrashReporter.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.monitoring 2 | 3 | import com.google.firebase.crashlytics.FirebaseCrashlytics 4 | 5 | class CrashlyticsCrashReporter : CrashReporter { 6 | override fun logMessage(message: String) = FirebaseCrashlytics.getInstance().log(message) 7 | 8 | override fun logException( 9 | message: String, 10 | ex: Exception, 11 | ) { 12 | FirebaseCrashlytics.getInstance().log(message) 13 | FirebaseCrashlytics.getInstance().recordException(ex) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/monitoring/LoggingOnlyCrashReporter.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.monitoring 2 | 3 | import timber.log.Timber 4 | 5 | class LoggingOnlyCrashReporter : CrashReporter { 6 | override fun logMessage(message: String) = Timber.i(message) 7 | 8 | override fun logException( 9 | message: String, 10 | ex: Exception, 11 | ) = Timber.e(ex, message) 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/notification/Notification.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.notification 2 | 3 | import android.app.NotificationManager 4 | import android.app.PendingIntent 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.media.RingtoneManager 8 | import android.net.Uri 9 | import androidx.core.app.NotificationCompat 10 | import androidx.core.content.ContextCompat 11 | import com.atomicrobot.carbon.R 12 | import com.atomicrobot.carbon.StartActivity 13 | import timber.log.Timber 14 | 15 | class Notification { 16 | fun sendNotification( 17 | context: Context, 18 | title: String?, 19 | body: String?, 20 | ) { 21 | Timber.d("sendNotification") 22 | val intent = Intent(context, StartActivity::class.java) 23 | intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 24 | val pendingIntent = 25 | PendingIntent.getActivity( 26 | context, 27 | 0, 28 | intent, 29 | PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, 30 | ) 31 | val defaultSoundUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) 32 | val notificationBuilder: NotificationCompat.Builder = 33 | NotificationCompat.Builder( 34 | context, 35 | context.getString(R.string.default_notification_channel_id), 36 | ) 37 | .setSmallIcon(R.drawable.ic_bell_notification) 38 | .setColor(ContextCompat.getColor(context.applicationContext, R.color.colorAccent)) 39 | .setContentTitle(title) 40 | .setContentText(body) 41 | .setAutoCancel(true) 42 | .setSound(defaultSoundUri) 43 | .setContentIntent(pendingIntent) 44 | val notificationManager = 45 | context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 46 | notificationManager.notify(0, notificationBuilder.build()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/about/AboutHtmlScreen.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.about 2 | 3 | import android.view.ViewGroup 4 | import android.webkit.WebView 5 | import android.webkit.WebViewClient 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import androidx.compose.ui.viewinterop.AndroidView 9 | 10 | @Preview(showBackground = true) 11 | @Composable 12 | fun AboutHtmlScreen() { 13 | val mUrl = "https://atomicrobot.com/about/" 14 | AndroidView(factory = { 15 | WebView(it).apply { 16 | layoutParams = 17 | ViewGroup.LayoutParams( 18 | ViewGroup.LayoutParams.MATCH_PARENT, 19 | ViewGroup.LayoutParams.MATCH_PARENT, 20 | ) 21 | webViewClient = WebViewClient() 22 | loadUrl(mUrl) 23 | } 24 | }, update = { 25 | it.loadUrl(mUrl) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/clickableCards/GitCardInfoScreen.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.clickableCards 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material3.Checkbox 13 | import androidx.compose.material3.CircularProgressIndicator 14 | import androidx.compose.material3.Scaffold 15 | import androidx.compose.material3.SnackbarHostState 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.runtime.collectAsState 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.text.font.FontWeight 25 | import androidx.compose.ui.tooling.preview.Preview 26 | import androidx.compose.ui.unit.dp 27 | import com.atomicrobot.carbon.data.api.github.model.DetailedCommit 28 | import org.koin.androidx.compose.koinViewModel 29 | 30 | @Composable 31 | fun GitCardInfoScreen( 32 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, 33 | sha: String, 34 | ) { 35 | val viewModel: GitCardInfoViewModel = koinViewModel() 36 | val screenState by viewModel.uiState.collectAsState() 37 | 38 | LaunchedEffect(key1 = true) { 39 | viewModel.fetchDetailedCommit(sha = sha) 40 | } 41 | Scaffold { padding -> 42 | 43 | Column( 44 | modifier = 45 | Modifier 46 | .fillMaxSize() 47 | .padding(padding), 48 | verticalArrangement = Arrangement.Top, 49 | horizontalAlignment = Alignment.CenterHorizontally, 50 | ) { 51 | DetailedGitInfoResponse( 52 | detailedCommitState = screenState.detailedCommitState, 53 | snackbarHostState = snackbarHostState, 54 | modifier = Modifier.weight(1f), 55 | ) 56 | } 57 | } 58 | } 59 | 60 | @Composable 61 | fun DetailedGitInfoResponse( 62 | detailedCommitState: GitCardInfoViewModel.GitHubResponse, 63 | snackbarHostState: SnackbarHostState, 64 | modifier: Modifier = Modifier, 65 | ) { 66 | Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { 67 | when (detailedCommitState) { 68 | is GitCardInfoViewModel.GitHubResponse.Loading -> 69 | CircularProgressIndicator() 70 | is GitCardInfoViewModel.GitHubResponse.Error -> 71 | LaunchedEffect(snackbarHostState) { 72 | snackbarHostState.showSnackbar(message = detailedCommitState.message) 73 | } 74 | is GitCardInfoViewModel.GitHubResponse.Result -> Details(details = detailedCommitState.commit) 75 | } 76 | } 77 | } 78 | 79 | @Composable 80 | fun Details(details: DetailedCommit?) { 81 | Column( 82 | modifier = 83 | Modifier 84 | .fillMaxSize() 85 | .padding(16.dp) 86 | .verticalScroll(rememberScrollState()), 87 | ) { 88 | if (details?.detailedCommitMessage != null) { 89 | Row { 90 | Text(text = "Author: ", fontWeight = FontWeight.Bold) 91 | Text(details.detailedCommitAuthor) 92 | } 93 | Text(text = "Message: ", fontWeight = FontWeight.Bold) 94 | Text(text = details.detailedCommitMessage) 95 | Text(text = "TreeUrl :", fontWeight = FontWeight.Bold) 96 | Text(text = details.detailedCommitTreeURL) 97 | Row { 98 | Text(text = "Verified: ", fontWeight = FontWeight.Bold) 99 | Checkbox(checked = details.detailedCommitVerified, onCheckedChange = null) 100 | } 101 | } else { 102 | Text(text = "An error has occurred retrieving or displaying this commit") 103 | } 104 | } 105 | } 106 | 107 | @Preview(showBackground = true) 108 | @Composable 109 | fun CardInfoScreenPreview() { 110 | Details(dummyDetailedCommit[0]) 111 | } 112 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/clickableCards/GitCardInfoViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.clickableCards 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.atomicrobot.carbon.R 7 | import com.atomicrobot.carbon.data.api.github.GitHubInteractor 8 | import com.atomicrobot.carbon.data.api.github.model.DetailedCommit 9 | import com.atomicrobot.carbon.ui.main.MainViewModel 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.launch 13 | import timber.log.Timber 14 | 15 | class GitCardInfoViewModel( 16 | private val app: Application, 17 | private val gitHubInteractor: GitHubInteractor, 18 | ) : ViewModel() { 19 | sealed class GitHubResponse { 20 | object Loading : GitHubResponse() 21 | 22 | class Result(val commit: DetailedCommit?) : GitHubResponse() 23 | 24 | class Error(val message: String) : GitHubResponse() 25 | } 26 | 27 | data class GitInfoScreenUiState( 28 | // NON-NLS 29 | val username: String = MainViewModel.DEFAULT_USERNAME, 30 | // NON-NLS 31 | val repository: String = MainViewModel.DEFAULT_REPO, 32 | val detailedCommitState: GitHubResponse = GitHubResponse.Result(null), 33 | ) 34 | 35 | private val _uiState = MutableStateFlow(GitInfoScreenUiState()) 36 | val uiState: StateFlow 37 | get() = _uiState 38 | 39 | fun fetchDetailedCommit(sha: String) { 40 | // Update the UI state to indicate that we are loading. 41 | _uiState.value = 42 | _uiState.value.copy( 43 | detailedCommitState = GitHubResponse.Loading, 44 | ) 45 | viewModelScope.launch { 46 | try { 47 | /*Passes in active users credentials to interactor which will make use an API 48 | service to make a @Get request with said credentials*/ 49 | gitHubInteractor.loadDetailedCommit( 50 | GitHubInteractor.LoadDetailedCommitRequest( 51 | uiState.value.username, 52 | uiState.value.repository, 53 | sha, 54 | ), 55 | ).let { 56 | _uiState.value = 57 | _uiState.value.copy( 58 | detailedCommitState = GitHubResponse.Result(it.commit), 59 | ) 60 | } 61 | } catch (error: Exception) { 62 | Timber.e(error) 63 | _uiState.value = 64 | _uiState.value.copy( 65 | detailedCommitState = 66 | GitHubResponse.Error( 67 | error.message 68 | ?: app.getString(R.string.error_unexpected), 69 | ), 70 | ) 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/clickableCards/SampleData2.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.clickableCards 2 | 3 | import com.atomicrobot.carbon.data.api.github.model.Author 4 | import com.atomicrobot.carbon.data.api.github.model.DetailedCommit 5 | import com.atomicrobot.carbon.data.api.github.model.DetailedCommitDetails 6 | import com.atomicrobot.carbon.data.api.github.model.Tree 7 | import com.atomicrobot.carbon.data.api.github.model.Verification 8 | 9 | val dummyDetailedCommit = 10 | listOf( 11 | DetailedCommit( 12 | detailedCommit = 13 | DetailedCommitDetails( 14 | message = "Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium", 15 | author = Author("Test Testerman", email = "email@example.com", date = "2023-04-01"), 16 | tree = 17 | Tree( 18 | url = "exampletree@example.com", 19 | ), 20 | verification = Verification(true), 21 | ), 22 | ), 23 | ) 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/deeplink/DeepLinkSampleScreen.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.deeplink 2 | 3 | import android.graphics.Color 4 | import androidx.compose.foundation.background 5 | import androidx.compose.material3.Surface 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.sp 11 | import androidx.compose.ui.graphics.Color as ComposeColor 12 | 13 | @Preview(widthDp = 360, heightDp = 720) 14 | @Composable 15 | fun DeepLinkSampleScreen( 16 | textColor: Int = Color.BLACK, 17 | textSize: Float = 30f, 18 | ) { 19 | Surface( 20 | modifier = Modifier.background(ComposeColor.White), 21 | ) { 22 | Text( 23 | "Deep Link Sample Fragment", 24 | fontSize = textSize.sp, 25 | color = ComposeColor(textColor), 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/license/LicenseScreen.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.license 2 | 3 | import android.text.method.LinkMovementMethod 4 | import android.view.ViewGroup.LayoutParams.MATCH_PARENT 5 | import android.widget.LinearLayout 6 | import android.widget.TextView 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.PaddingValues 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.material3.CircularProgressIndicator 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.SnackbarHostState 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.runtime.collectAsState 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.graphics.toArgb 25 | import androidx.compose.ui.platform.LocalContext 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.viewinterop.AndroidView 29 | import com.atomicrobot.carbon.ui.theme.CarbonAndroidTheme 30 | import io.noties.markwon.Markwon 31 | import org.koin.androidx.compose.koinViewModel 32 | 33 | @Composable 34 | fun LicenseScreen(snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }) { 35 | val viewModel: LicenseViewModel = koinViewModel() 36 | val screenState by viewModel.uiState.collectAsState() 37 | 38 | LaunchedEffect(true) { 39 | viewModel.getLicenses() 40 | } 41 | 42 | Column( 43 | modifier = Modifier.fillMaxSize(), 44 | verticalArrangement = Arrangement.Center, 45 | horizontalAlignment = Alignment.CenterHorizontally, 46 | ) { 47 | LicensesResponse( 48 | screenState.licensesState, 49 | snackbarHostState, 50 | ) 51 | } 52 | } 53 | 54 | @Composable 55 | fun LicensesResponse( 56 | licensesState: LicenseViewModel.Licenses, 57 | snackbarHostState: SnackbarHostState, 58 | modifier: Modifier = Modifier, 59 | ) { 60 | Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { 61 | when (licensesState) { 62 | is LicenseViewModel.Licenses.Loading -> 63 | CircularProgressIndicator() 64 | is LicenseViewModel.Licenses.Error -> 65 | LaunchedEffect(snackbarHostState) { 66 | snackbarHostState.showSnackbar(message = licensesState.message) 67 | } 68 | is LicenseViewModel.Licenses.Result -> LicensesList(licenses = licensesState.licenses) 69 | } 70 | } 71 | } 72 | 73 | @Composable 74 | fun LicensesList(licenses: String) { 75 | val context = LocalContext.current 76 | val markwon = Markwon.create(context) 77 | val markdown = markwon.toMarkdown(licenses) 78 | val textColor = MaterialTheme.colorScheme.onBackground.toArgb() 79 | LazyColumn(contentPadding = PaddingValues(top = 8.dp, start = 16.dp, end = 16.dp)) { 80 | item { 81 | AndroidView(factory = { 82 | TextView(it).apply { 83 | layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) 84 | movementMethod = LinkMovementMethod.getInstance() 85 | setTextColor(textColor) 86 | } 87 | }, update = { 88 | it.text = markdown 89 | }) 90 | } 91 | } 92 | } 93 | 94 | @Preview(showBackground = true) 95 | @Composable 96 | fun LicenseScreenPreview() { 97 | CarbonAndroidTheme { 98 | LicenseScreen() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/license/LicenseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.license 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.atomicrobot.carbon.R 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.launch 10 | import timber.log.Timber 11 | import java.io.InputStream 12 | 13 | class LicenseViewModel( 14 | private val app: Application, 15 | ) : ViewModel() { 16 | sealed class Licenses { 17 | object Loading : Licenses() 18 | 19 | class Result(val licenses: String) : Licenses() 20 | 21 | class Error(val message: String) : Licenses() 22 | } 23 | 24 | data class LicenseScreenUiState( 25 | val licensesState: Licenses = Licenses.Result(""), 26 | ) 27 | 28 | private val _uiState = MutableStateFlow(LicenseScreenUiState()) 29 | val uiState: StateFlow 30 | get() = _uiState 31 | 32 | fun getLicenses() { 33 | // Update the UI state to indicate that we are loading. 34 | _uiState.value = _uiState.value.copy(licensesState = Licenses.Loading) 35 | // Try to load licenses from markdown file 36 | viewModelScope.launch { 37 | try { 38 | kotlin.runCatching { 39 | val inputStream: InputStream = app.assets.open("licenses.md") 40 | val size: Int = inputStream.available() 41 | val buffer = ByteArray(size) 42 | inputStream.read(buffer) 43 | String(buffer) 44 | }.onSuccess { 45 | _uiState.value = _uiState.value.copy(licensesState = Licenses.Result(it)) 46 | }.onFailure { 47 | _uiState.value = 48 | _uiState.value.copy( 49 | licensesState = 50 | Licenses.Error( 51 | app.getString(R.string.error_unexpected), 52 | ), 53 | ) 54 | } 55 | } catch (error: Exception) { 56 | Timber.e(error) 57 | _uiState.value = 58 | _uiState.value.copy( 59 | licensesState = 60 | Licenses.Error( 61 | error.message ?: app.getString(R.string.error_unexpected), 62 | ), 63 | ) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/lumen/LumenConverters.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.lumen 2 | 3 | import androidx.room.TypeConverter 4 | import com.atomicrobot.carbon.data.lumen.dto.LightType 5 | 6 | class LumenConverters { 7 | @TypeConverter 8 | fun fromOrdinal(ordinal: Int?): LightType? = ordinal?.let { LightType.entries[it] } 9 | 10 | @TypeConverter 11 | fun lightTypeToInt(type: LightType?): Int? = type?.ordinal 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/lumen/LumenIndeterminateIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.lumen 2 | 3 | import androidx.compose.animation.core.LinearEasing 4 | import androidx.compose.animation.core.animateFloat 5 | import androidx.compose.animation.core.infiniteRepeatable 6 | import androidx.compose.animation.core.rememberInfiniteTransition 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.foundation.Image 9 | import androidx.compose.foundation.progressSemantics 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.graphicsLayer 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import com.atomicrobot.carbon.R 18 | 19 | @Preview 20 | @Composable 21 | fun LumenIndeterminateIndicator(modifier: Modifier = Modifier) { 22 | val infiniteTransition = rememberInfiniteTransition() 23 | val rotation by infiniteTransition.animateFloat( 24 | initialValue = 0F, 25 | targetValue = 360F, 26 | animationSpec = 27 | infiniteRepeatable( 28 | animation = tween(durationMillis = 1000, easing = LinearEasing), 29 | ), 30 | ) 31 | Image( 32 | painter = painterResource(id = R.drawable.ic_loading_icon), 33 | contentDescription = stringResource(id = R.string.cont_desc_loading), 34 | modifier = 35 | modifier 36 | .progressSemantics() 37 | .graphicsLayer { rotationZ = rotation }, 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/lumen/home/LumenHome.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.lumen.home 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | fun LumenHomeScreen() {} 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/lumen/routines/LumenRoutines.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.lumen.routines 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | fun LumenRoutinesScreen() {} 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/lumen/settings/LumenSettings.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.lumen.settings 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | fun LumenSettingsScreen() {} 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.main 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.atomicrobot.carbon.BuildConfig 7 | import com.atomicrobot.carbon.R 8 | import com.atomicrobot.carbon.data.api.github.GitHubInteractor 9 | import com.atomicrobot.carbon.data.api.github.model.Commit 10 | import kotlinx.coroutines.flow.MutableSharedFlow 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.launch 14 | import timber.log.Timber 15 | 16 | class MainViewModel( 17 | private val app: Application, 18 | private val gitHubInteractor: GitHubInteractor, 19 | private val loadingDelayMs: Long, 20 | ) : ViewModel() { 21 | sealed class Commits { 22 | object Loading : Commits() 23 | 24 | class Result(val commits: List) : Commits() 25 | 26 | class Error(val message: String) : Commits() 27 | } 28 | 29 | sealed class CardClicked { 30 | data class PassSha(val sha: String) : CardClicked() 31 | 32 | object Clicked : CardClicked() 33 | } 34 | 35 | sealed class ClickAction { 36 | object Success : ClickAction() 37 | } 38 | 39 | data class MainScreenUiState( 40 | // NON-NLS 41 | val username: String = DEFAULT_USERNAME, 42 | // NON-NLS 43 | val repository: String = DEFAULT_REPO, 44 | val commitsState: Commits = Commits.Result(emptyList()), 45 | val sha: String = "", 46 | ) 47 | 48 | private val _uiState = MutableStateFlow(MainScreenUiState()) 49 | val uiState: StateFlow 50 | get() = _uiState 51 | 52 | fun updateUserInput( 53 | username: String?, 54 | repository: String?, 55 | ) { 56 | _uiState.value = 57 | _uiState.value.copy( 58 | username = username ?: _uiState.value.username, 59 | repository = repository ?: _uiState.value.repository, 60 | ) 61 | } 62 | 63 | fun fetchCommits() { 64 | // Update the UI state to indicate that we are loading. 65 | _uiState.value = _uiState.value.copy(commitsState = Commits.Loading) 66 | // Try and fetching the commit records 67 | viewModelScope.launch { 68 | try { 69 | gitHubInteractor.loadCommits( 70 | GitHubInteractor.LoadCommitsRequest( 71 | uiState.value.username, 72 | uiState.value.repository, 73 | ), 74 | ).let { 75 | _uiState.value = _uiState.value.copy(commitsState = Commits.Result(it.commits)) 76 | } 77 | } catch (error: Exception) { 78 | Timber.e(error) 79 | _uiState.value = 80 | _uiState.value.copy( 81 | commitsState = 82 | Commits.Error( 83 | error.message 84 | ?: app.getString(R.string.error_unexpected), 85 | ), 86 | ) 87 | } 88 | } 89 | } 90 | 91 | fun getVersion() = BuildConfig.VERSION_NAME 92 | 93 | fun getVersionFingerprint() = BuildConfig.VERSION_FINGERPRINT 94 | 95 | fun onAction(action: CardClicked) { 96 | when (action) { 97 | is CardClicked.Clicked -> { 98 | viewModelScope.launch { 99 | clickAction.emit(ClickAction.Success) 100 | } 101 | } 102 | is CardClicked.PassSha -> { 103 | viewModelScope.launch { 104 | _uiState.value = 105 | _uiState.value.copy( 106 | sha = action.sha, 107 | ) 108 | clickAction.emit(ClickAction.Success) 109 | } 110 | } 111 | } 112 | } 113 | 114 | val clickAction = MutableSharedFlow() 115 | 116 | companion object { 117 | const val DEFAULT_USERNAME = "madebyatomicrobot" // NON-NLS 118 | const val DEFAULT_REPO = "android-starter-project" // NON-NLS 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/main/SampleData.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.main 2 | 3 | import com.atomicrobot.carbon.data.api.github.model.Author 4 | import com.atomicrobot.carbon.data.api.github.model.Commit 5 | import com.atomicrobot.carbon.data.api.github.model.CommitDetails 6 | 7 | val dummyCommits = 8 | listOf( 9 | Commit( 10 | sha = "", 11 | commit = 12 | CommitDetails( 13 | message = "Sample github commit message #1", 14 | author = Author("Smitty Joe", email = "email@example.com", date = "2023-01-01"), 15 | ), 16 | ), 17 | Commit( 18 | sha = "", 19 | commit = 20 | CommitDetails( 21 | message = "Sample github commit message #2", 22 | author = Author("Joe Smitty", email = "email@example.com", date = "2023-01-01"), 23 | ), 24 | ), 25 | Commit( 26 | sha = "", 27 | commit = 28 | CommitDetails( 29 | message = "Sample github commit message #3", 30 | author = Author("Smith Joe", email = "email@example.com", date = "2023-01-01"), 31 | ), 32 | ), 33 | Commit( 34 | sha = "", 35 | commit = 36 | CommitDetails( 37 | message = "Sample github commit message #4", 38 | author = Author("Joe Smith", email = "email@example.com", date = "2023-01-01"), 39 | ), 40 | ), 41 | Commit( 42 | sha = "", 43 | commit = 44 | CommitDetails( 45 | message = "Sample github commit message #5", 46 | author = Author("Joe Smoe", email = "email@example.com", date = "2023-01-01"), 47 | ), 48 | ), 49 | ) 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/navigation/Drawer.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.navigation 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxHeight 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.size 14 | import androidx.compose.foundation.layout.wrapContentWidth 15 | import androidx.compose.foundation.shape.CircleShape 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.draw.clip 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.res.painterResource 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.compose.ui.tooling.preview.PreviewParameter 28 | import androidx.compose.ui.unit.dp 29 | import com.atomicrobot.carbon.R 30 | import com.atomicrobot.carbon.navigation.CarbonScreens 31 | import com.atomicrobot.carbon.util.AppScreenPreviewProvider 32 | import com.atomicrobot.carbon.util.AppScreensPreviewProvider 33 | 34 | @Preview 35 | @Composable 36 | fun Drawer( 37 | @PreviewParameter(AppScreensPreviewProvider::class, limit = 1) screens: List, 38 | modifier: Modifier = Modifier.background(color = MaterialTheme.colorScheme.background), 39 | onDestinationClicked: (route: String) -> Unit = { _ -> }, 40 | ) { 41 | Column( 42 | modifier 43 | .fillMaxHeight() 44 | .padding(start = 24.dp, end = 24.dp, top = 48.dp), 45 | ) { 46 | Box( 47 | Modifier 48 | .clip(CircleShape) 49 | .background( 50 | Color.Gray, 51 | ), 52 | ) { 53 | Image( 54 | painter = painterResource(id = R.drawable.ic_launcher_foreground), 55 | contentDescription = "App Icon foreground", 56 | ) 57 | } 58 | screens.forEach { screen -> 59 | Spacer(Modifier.height(24.dp)) 60 | DrawerAppScreenItem(screen, onDestinationClicked) 61 | } 62 | } 63 | } 64 | 65 | @Preview(showBackground = true) 66 | @Composable 67 | fun DrawerAppScreenItem( 68 | @PreviewParameter(AppScreenPreviewProvider::class, limit = 3) screen: CarbonScreens, 69 | onDestinationClicked: (route: String) -> Unit = { _ -> }, 70 | ) { 71 | Row( 72 | Modifier 73 | .wrapContentWidth() 74 | .padding(end = 8.dp) 75 | .clickable 76 | { 77 | onDestinationClicked(screen.route) 78 | }, 79 | verticalAlignment = Alignment.CenterVertically, 80 | ) { 81 | val contentDesc = stringResource(id = screen.iconData.iconContentDescription) 82 | val modifier = 83 | Modifier 84 | .size(45.dp) 85 | .padding(8.dp) 86 | .align(Alignment.CenterVertically) 87 | if (screen.route == CarbonScreens.About.route || screen.route == CarbonScreens.AboutHtml.route) { 88 | Icon( 89 | painter = painterResource(id = R.drawable.carbon_android_logo), 90 | contentDescription = contentDesc, 91 | modifier = modifier, 92 | tint = MaterialTheme.colorScheme.onBackground, 93 | ) 94 | } else { 95 | Icon( 96 | imageVector = screen.iconData.vectorData, 97 | contentDescription = contentDesc, 98 | modifier = modifier, 99 | tint = MaterialTheme.colorScheme.onBackground, 100 | ) 101 | } 102 | Text( 103 | text = screen.title, 104 | style = MaterialTheme.typography.headlineLarge, 105 | color = MaterialTheme.colorScheme.onBackground, 106 | ) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/permission/RequestPermission.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.permission 2 | 3 | import androidx.activity.compose.rememberLauncherForActivityResult 4 | import androidx.activity.result.contract.ActivityResultContracts 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.compose.runtime.SideEffect 8 | import com.atomicrobot.carbon.util.Common 9 | import com.atomicrobot.carbon.util.LocalActivity 10 | import kotlinx.coroutines.CoroutineScope 11 | 12 | @Composable 13 | fun RequestPermission( 14 | permission: String, 15 | onShowRationale: suspend CoroutineScope.(String) -> PermissionRationaleResult = 16 | { PermissionRationaleResult.ActionPerformed }, 17 | onPermissionResult: (PermissionRequestResult) -> Unit, 18 | ) { 19 | val activity = LocalActivity.current 20 | 21 | // Create a permission request launcher that will received the result of the perm. request 22 | val permissionLauncher = 23 | rememberLauncherForActivityResult( 24 | contract = ActivityResultContracts.RequestPermission(), 25 | ) { isGranted: Boolean -> 26 | if (isGranted) { 27 | onPermissionResult(PermissionRequestResult.Granted) 28 | } else { 29 | onPermissionResult(PermissionRequestResult.Denied) 30 | } 31 | } 32 | 33 | when { 34 | Common.hasPermission(activity, permission) -> 35 | onPermissionResult(PermissionRequestResult.Granted) 36 | activity.shouldShowRequestPermissionRationale(permission) -> { 37 | LaunchedEffect(permission) { 38 | if (onShowRationale(permission) == PermissionRationaleResult.ActionPerformed) { 39 | permissionLauncher.launch(permission) 40 | } else { 41 | onPermissionResult(PermissionRequestResult.Denied) 42 | } 43 | } 44 | } 45 | else -> { 46 | SideEffect { 47 | permissionLauncher.launch(permission) 48 | } 49 | } 50 | } 51 | } 52 | 53 | @Composable 54 | fun RequestPermissions( 55 | permissions: Array, 56 | onShowRationale: suspend CoroutineScope.(String) -> PermissionRationaleResult, 57 | onPermissionResult: (PermissionRequestResult) -> Unit, 58 | ) { 59 | val activity = LocalActivity.current 60 | 61 | // Create a permission request launcher that will received the result of the perm. request 62 | val permissionLauncher = 63 | rememberLauncherForActivityResult( 64 | contract = ActivityResultContracts.RequestMultiplePermissions(), 65 | ) { results: Map -> 66 | if (results.all(Map.Entry::value)) { 67 | onPermissionResult(PermissionRequestResult.Granted) 68 | } else { 69 | onPermissionResult(PermissionRequestResult.Denied) 70 | } 71 | } 72 | 73 | when { 74 | permissions.all { Common.hasPermission(activity, it) } -> 75 | onPermissionResult(PermissionRequestResult.Granted) 76 | else -> { 77 | permissions.forEach { 78 | if (activity.shouldShowRequestPermissionRationale(it)) { 79 | LaunchedEffect(it) { 80 | if (onShowRationale(it) == PermissionRationaleResult.ActionPerformed) { 81 | permissionLauncher.launch(permissions) 82 | } else { 83 | onPermissionResult(PermissionRequestResult.Denied) 84 | } 85 | } 86 | } 87 | } 88 | 89 | SideEffect { 90 | permissionLauncher.launch(permissions) 91 | } 92 | } 93 | } 94 | } 95 | 96 | sealed class PermissionRequestResult { 97 | object Granted : PermissionRequestResult() 98 | 99 | object Denied : PermissionRequestResult() 100 | } 101 | 102 | sealed class PermissionRationaleResult { 103 | object Dismissed : PermissionRationaleResult() 104 | 105 | object ActionPerformed : PermissionRationaleResult() 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.settings 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | 12 | @Composable 13 | fun SettingsScreen() { 14 | Column( 15 | modifier = Modifier.fillMaxSize(), 16 | verticalArrangement = Arrangement.Center, 17 | horizontalAlignment = Alignment.CenterHorizontally, 18 | ) { 19 | Text(text = "Settings", style = MaterialTheme.typography.headlineLarge) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/shader/AngledLinearGradient.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.shader 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.geometry.Offset 5 | import androidx.compose.ui.geometry.Size 6 | import androidx.compose.ui.geometry.center 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.graphics.LinearGradientShader 9 | import androidx.compose.ui.graphics.Shader 10 | import androidx.compose.ui.graphics.ShaderBrush 11 | import androidx.compose.ui.graphics.TileMode 12 | import kotlin.math.PI 13 | import kotlin.math.abs 14 | import kotlin.math.acos 15 | import kotlin.math.cos 16 | import kotlin.math.pow 17 | import kotlin.math.sin 18 | import kotlin.math.sqrt 19 | 20 | // https://medium.com/@bimurat.mukhtar/how-to-implement-linear-gradient-with-any-angle-in-jetpack-compose-3ded798c81f5 21 | 22 | /** 23 | * Creates a linear gradient with the provided colors 24 | * and angle. 25 | * 26 | * @param colors Colors of gradient 27 | * @param stops Offsets to determine how the colors are dispersed throughout 28 | * the vertical gradient 29 | * @param tileMode Determines the behavior for how the shader is to fill a region outside 30 | * its bounds. Defaults to [TileMode.Clamp] to repeat the edge pixels 31 | * @param angleInDegrees Angle of a gradient in degrees 32 | * @param useAsCssAngle Determines whether the CSS gradient angle should be used 33 | * by default cartesian angle is used 34 | * 35 | * @see 36 | * linear-gradient 37 | */ 38 | @Immutable 39 | class AngledLinearGradient constructor( 40 | private val colors: List, 41 | private val stops: List? = null, 42 | private val tileMode: TileMode = TileMode.Clamp, 43 | angleInDegrees: Float = 0f, 44 | useAsCssAngle: Boolean = false, 45 | ) : ShaderBrush() { 46 | // handle edge cases like: -1235, ... 47 | private val normalizedAngle: Float = 48 | if (useAsCssAngle) { 49 | ((90 - angleInDegrees) % 360 + 360) % 360 50 | } else { 51 | (angleInDegrees % 360 + 360) % 360 52 | } 53 | private val angleInRadians: Float = Math.toRadians(normalizedAngle.toDouble()).toFloat() 54 | 55 | override fun createShader(size: Size): Shader { 56 | val (from, to) = getGradientCoordinates(size = size) 57 | 58 | return LinearGradientShader( 59 | colors = colors, 60 | colorStops = stops, 61 | from = from, 62 | to = to, 63 | tileMode = tileMode, 64 | ) 65 | } 66 | 67 | private fun getGradientCoordinates(size: Size): Pair { 68 | val diagonal = sqrt(size.width.pow(2) + size.height.pow(2)) 69 | val angleBetweenDiagonalAndWidth = acos(size.width / diagonal) 70 | val angleBetweenDiagonalAndGradientLine = 71 | if ((normalizedAngle > 90 && normalizedAngle < 180) || 72 | (normalizedAngle > 270 && normalizedAngle < 360) 73 | ) { 74 | PI.toFloat() - angleInRadians - angleBetweenDiagonalAndWidth 75 | } else { 76 | angleInRadians - angleBetweenDiagonalAndWidth 77 | } 78 | val halfGradientLine = abs(cos(angleBetweenDiagonalAndGradientLine) * diagonal) / 2 79 | 80 | val horizontalOffset = halfGradientLine * cos(angleInRadians) 81 | val verticalOffset = halfGradientLine * sin(angleInRadians) 82 | 83 | val start = size.center + Offset(-horizontalOffset, verticalOffset) 84 | val end = size.center + Offset(horizontalOffset, -verticalOffset) 85 | 86 | return start to end 87 | } 88 | 89 | override fun equals(other: Any?): Boolean { 90 | if (this === other) return true 91 | if (other !is AngledLinearGradient) return false 92 | 93 | if (colors != other.colors) return false 94 | if (stops != other.stops) return false 95 | if (normalizedAngle != other.normalizedAngle) return false 96 | if (tileMode != other.tileMode) return false 97 | 98 | return true 99 | } 100 | 101 | override fun hashCode(): Int { 102 | var result = colors.hashCode() 103 | result = 31 * result + (stops?.hashCode() ?: 0) 104 | result = 31 * result + normalizedAngle.hashCode() 105 | result = 31 * result + tileMode.hashCode() 106 | return result 107 | } 108 | 109 | override fun toString(): String { 110 | return "LinearGradient(colors=$colors, " + 111 | "stops=$stops, " + 112 | "angle=$normalizedAngle, " + 113 | "tileMode=$tileMode)" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.splash 2 | 3 | import android.net.Uri 4 | import androidx.lifecycle.ViewModel 5 | import com.atomicrobot.carbon.deeplink.DeepLinkInteractor 6 | import javax.inject.Inject 7 | 8 | class SplashViewModel 9 | @Inject 10 | constructor( 11 | private val deepLinkInteractor: DeepLinkInteractor, 12 | ) : ViewModel() { 13 | fun setDeepLinkUri(uri: Uri?) { 14 | deepLinkInteractor.setDeepLinkUri(uri) 15 | } 16 | 17 | fun setDeepLinkPath(path: String?) { 18 | deepLinkInteractor.setDeepLinkPath(path) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val carbonShapes = 8 | Shapes( 9 | small = RoundedCornerShape(0.dp), 10 | medium = RoundedCornerShape(8.dp), 11 | large = RoundedCornerShape(24.dp), 12 | ) 13 | 14 | val carbonShellShapes = 15 | Shapes( 16 | small = RoundedCornerShape(0.dp), 17 | medium = RoundedCornerShape(24.dp), 18 | large = RoundedCornerShape(0.dp), 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.darkColorScheme 6 | import androidx.compose.material3.lightColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.SideEffect 9 | import androidx.compose.ui.graphics.Color 10 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 11 | 12 | private val DarkColorPalette = 13 | darkColorScheme( 14 | primary = Purple200, 15 | secondary = Purple700, 16 | secondaryContainer = Purple200, 17 | onSurfaceVariant = Mono800, 18 | surfaceContainer = Purple200, 19 | ) 20 | 21 | private val LightColorPalette = 22 | lightColorScheme( 23 | primary = Purple500, 24 | secondary = Purple700, 25 | secondaryContainer = Purple500, 26 | onSecondaryContainer = White100, 27 | background = White100, 28 | onBackground = Black100, 29 | onSurface = Black100, 30 | onSurfaceVariant = White75, 31 | surfaceContainer = Purple500, 32 | surfaceContainerHighest = White100, 33 | ) 34 | 35 | @Composable 36 | fun CarbonAndroidTheme( 37 | darkTheme: Boolean = isSystemInDarkTheme(), 38 | content: @Composable () -> Unit, 39 | ) { 40 | /** 41 | * This will allow you to address the system bars (status bar and navigation bar) without 42 | * needing to define it within the styles.xml 43 | * 44 | * IMPORTANT NOTE: 45 | * This remember call will persist the system bar changes across all screens. If you need 46 | * different colors for your system bars in other themes, you will need to override the colors 47 | * in that theme, as well. 48 | * 49 | * UPDATE: 50 | * With API 35, UI will now draw edge-to-edge by default. If using Material3 and minSdk is set 51 | * to 35, then you can completely remove systemUiController logic. 52 | */ 53 | val systemUiController = rememberSystemUiController() 54 | SideEffect { 55 | systemUiController.setSystemBarsColor( 56 | color = Neutron, 57 | ) 58 | } 59 | 60 | MaterialTheme( 61 | colorScheme = 62 | if (darkTheme) { 63 | DarkColorPalette 64 | } else { 65 | LightColorPalette 66 | }, 67 | typography = Typography, 68 | content = content, 69 | ) 70 | } 71 | 72 | private val CarbonShellPalette = 73 | lightColorScheme( 74 | primary = Neutron, 75 | onPrimary = White100, 76 | surface = Mono800, 77 | onSurface = White100, 78 | ) 79 | 80 | @Composable 81 | fun CarbonShellTheme( 82 | @Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), 83 | content: @Composable () -> Unit, 84 | ) { 85 | val systemUiController = rememberSystemUiController() 86 | SideEffect { 87 | systemUiController.setSystemBarsColor( 88 | color = Neutron, 89 | ) 90 | } 91 | 92 | MaterialTheme( 93 | colorScheme = CarbonShellPalette, 94 | shapes = carbonShellShapes, 95 | content = content, 96 | ) 97 | } 98 | 99 | private val LumenColorPalette = 100 | lightColorScheme( 101 | primary = DarkBlurple, 102 | onPrimary = White100, 103 | surface = DarkBlurple, 104 | onSurface = White100, 105 | secondaryContainer = DarkBlurple, 106 | onSurfaceVariant = Color.Transparent, 107 | surfaceContainer = DarkBlurple, 108 | surfaceContainerHighest = Color.Transparent, 109 | surfaceContainerLow = LumenPurple, 110 | ) 111 | 112 | @Composable 113 | fun LumenTheme( 114 | @Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), 115 | content: @Composable () -> Unit, 116 | ) { 117 | val systemUiController = rememberSystemUiController() 118 | SideEffect { 119 | systemUiController.setSystemBarsColor( 120 | color = Neutron, 121 | ) 122 | } 123 | 124 | MaterialTheme( 125 | colorScheme = LumenColorPalette, 126 | typography = LumenTypography, 127 | shapes = carbonShapes, 128 | content = content, 129 | ) 130 | } 131 | 132 | private val ScannerColorPalette = 133 | lightColorScheme( 134 | primary = Neutron, 135 | onPrimary = White100, 136 | surface = Mono800, 137 | onSurface = White100, 138 | ) 139 | 140 | @Composable 141 | fun ScannerTheme( 142 | @Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), 143 | content: @Composable () -> Unit, 144 | ) { 145 | val systemUiController = rememberSystemUiController() 146 | SideEffect { 147 | systemUiController.setSystemBarsColor( 148 | color = Neutron, 149 | ) 150 | } 151 | 152 | MaterialTheme( 153 | colorScheme = ScannerColorPalette, 154 | typography = Typography, 155 | content = content, 156 | ) 157 | } 158 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.Font 6 | import androidx.compose.ui.text.font.FontFamily 7 | import androidx.compose.ui.text.font.FontWeight 8 | import androidx.compose.ui.unit.sp 9 | import com.atomicrobot.carbon.R 10 | 11 | // Set of Material typography styles to start with 12 | val Typography = 13 | Typography( 14 | bodyLarge = 15 | TextStyle( 16 | fontFamily = FontFamily.Default, 17 | fontWeight = FontWeight.Normal, 18 | fontSize = 16.sp, 19 | ), 20 | ) 21 | 22 | val Lexend = 23 | FontFamily( 24 | Font(R.font.lexend_regular), 25 | Font(R.font.lexend_bold, FontWeight.Bold), 26 | Font(R.font.lexend_medium, FontWeight.Medium), 27 | Font(R.font.lexend_light, FontWeight.Light), 28 | ) 29 | 30 | val LumenTypography = 31 | Typography( 32 | displayLarge = 33 | TextStyle( 34 | fontFamily = Lexend, 35 | fontWeight = FontWeight.Medium, 36 | fontSize = 20.sp, 37 | lineHeight = 24.sp, 38 | ), 39 | displayMedium = 40 | TextStyle( 41 | fontFamily = Lexend, 42 | fontWeight = FontWeight.Medium, 43 | fontSize = 16.sp, 44 | lineHeight = 24.sp, 45 | ), 46 | headlineLarge = 47 | TextStyle( 48 | fontFamily = Lexend, 49 | fontWeight = FontWeight.Light, 50 | fontSize = 16.sp, 51 | lineHeight = 24.sp, 52 | ), 53 | headlineSmall = 54 | TextStyle( 55 | fontFamily = Lexend, 56 | fontWeight = FontWeight.Medium, 57 | fontSize = 12.sp, 58 | lineHeight = 16.sp, 59 | ), 60 | bodyLarge = 61 | TextStyle( 62 | fontFamily = Lexend, 63 | fontWeight = FontWeight.Light, 64 | fontSize = 14.sp, 65 | lineHeight = 16.sp, 66 | ), 67 | bodyMedium = 68 | TextStyle( 69 | fontFamily = Lexend, 70 | fontWeight = FontWeight.Light, 71 | fontSize = 12.sp, 72 | lineHeight = 16.sp, 73 | ), 74 | ) 75 | 76 | val Typography.ScreenHeading: TextStyle 77 | get() = 78 | TextStyle( 79 | fontFamily = Lexend, 80 | fontWeight = FontWeight.Bold, 81 | fontSize = 36.sp, 82 | lineHeight = 40.sp, 83 | ) 84 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/util/AppLogger.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.util 2 | 3 | import org.koin.core.logger.Level 4 | import org.koin.core.logger.Logger 5 | import org.koin.core.logger.MESSAGE 6 | import timber.log.Timber 7 | 8 | class AppLogger : Logger() { 9 | override fun display( 10 | level: Level, 11 | msg: MESSAGE, 12 | ) { 13 | when (level) { 14 | Level.DEBUG -> Timber.d(msg) 15 | Level.ERROR -> Timber.e(msg) 16 | Level.INFO -> Timber.i(msg) 17 | Level.NONE -> {} 18 | Level.WARNING -> Timber.w(msg) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/util/CarbonCompositionLocals.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.util 2 | 3 | import androidx.activity.ComponentActivity 4 | import androidx.compose.runtime.staticCompositionLocalOf 5 | 6 | val LocalActivity = 7 | staticCompositionLocalOf { 8 | noLocalProvidedFor("LocalActivity") 9 | } 10 | 11 | private fun noLocalProvidedFor(name: String): Nothing { 12 | error("CompositionLocal $name not present") 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/util/Common.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.util 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.pm.PackageManager 6 | import androidx.core.content.ContextCompat 7 | 8 | object Common { 9 | fun hasPermission( 10 | context: Context, 11 | permission: String, 12 | ): Boolean { 13 | return ContextCompat.checkSelfPermission( 14 | context, 15 | permission, 16 | ) == PackageManager.PERMISSION_GRANTED 17 | } 18 | 19 | fun shouldShowPermissionRationale( 20 | context: Context, 21 | permission: String, 22 | ): Boolean { 23 | val activity = context as Activity? 24 | return activity?.shouldShowRequestPermissionRationale(permission) ?: false 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/util/ComposePreviewParameters.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.util 2 | 3 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider 4 | import com.atomicrobot.carbon.data.api.github.model.Commit 5 | import com.atomicrobot.carbon.navigation.CarbonScreens 6 | import com.atomicrobot.carbon.navigation.LumenScreens 7 | import com.atomicrobot.carbon.navigation.appScreens 8 | import com.atomicrobot.carbon.navigation.lumenScreens 9 | import com.atomicrobot.carbon.ui.main.dummyCommits 10 | 11 | class CommitPreviewProvider : PreviewParameterProvider { 12 | override val values: Sequence 13 | get() = dummyCommits.asSequence() 14 | } 15 | 16 | class AppScreenPreviewProvider : PreviewParameterProvider { 17 | override val values: Sequence 18 | get() = 19 | listOf( 20 | CarbonScreens.Home, 21 | CarbonScreens.Settings, 22 | ) 23 | .asSequence() 24 | } 25 | 26 | class AppScreensPreviewProvider : PreviewParameterProvider> { 27 | override val values: Sequence> 28 | get() = listOf(appScreens).asSequence() 29 | } 30 | 31 | class LumenScreensPreviewProvider : PreviewParameterProvider> { 32 | override val values: Sequence> 33 | get() = listOf(lumenScreens).asSequence() 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/util/MyFirebaseMessagingService.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.util 2 | 3 | import com.atomicrobot.carbon.notification.Notification 4 | import com.google.firebase.messaging.FirebaseMessagingService 5 | import com.google.firebase.messaging.RemoteMessage 6 | import timber.log.Timber 7 | 8 | class MyFirebaseMessagingService : FirebaseMessagingService() { 9 | override fun onNewToken(token: String) { 10 | // Note: Copy the token from logcat and paste into Firebase Cloud Messaging console to 11 | // target this device directly 12 | Timber.i("Refreshed token: $token") 13 | super.onNewToken(token) 14 | } 15 | 16 | override fun onMessageReceived(remoteMessage: RemoteMessage) { 17 | Timber.i("From: ${remoteMessage.from}") 18 | Timber.i("Notification Message Body: ${remoteMessage.notification!!.body}") 19 | 20 | Notification().sendNotification(this, remoteMessage.notification!!.title, remoteMessage.notification!!.body) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/atomicrobot/carbon/util/SharedPreferencesExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.util 2 | 3 | import android.content.SharedPreferences 4 | 5 | fun SharedPreferences.putOrClearPreference( 6 | key: String, 7 | put: Boolean, 8 | value: Any, 9 | ) { 10 | if (put) { 11 | val editor = edit() 12 | when (value) { 13 | is Boolean -> editor.putBoolean(key, value) 14 | is String -> editor.putString(key, value) 15 | } 16 | editor.apply() 17 | } else { 18 | edit().remove(key).apply() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_lumen_color_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-hdpi/ic_lumen_color_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_lumen_white_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-hdpi/ic_lumen_white_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/lumen_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-hdpi/lumen_project.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_lumen_color_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-mdpi/ic_lumen_color_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_lumen_white_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-mdpi/ic_lumen_white_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_lumen_color_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-xhdpi/ic_lumen_color_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_lumen_white_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-xhdpi/ic_lumen_white_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_lumen_color_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-xxhdpi/ic_lumen_color_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_lumen_white_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-xxhdpi/ic_lumen_white_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_lumen_color_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-xxxhdpi/ic_lumen_color_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_lumen_white_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable-xxxhdpi/ic_lumen_white_bulb.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/blurple.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/carbon_android_logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/feature_about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/drawable/feature_about.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_qr_code_scanner.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_bell_notification.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_loading_icon.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_add.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_bright_sun.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_bulb.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_clock.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_close.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_color.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_heart.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_heart_filled.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_home_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_logo.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_meatball.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_play_filled.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_scene_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_schedule_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_stop_filled.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_timer.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lumen_trash.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/font/lexend_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/font/lexend_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/lexend_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/font/lexend_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/lexend_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/font/lexend_medium.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/lexend_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/font/lexend_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | #FF0000FF 7 | #26A69A 8 | 9 | 10 | #FF101C1D 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/prod/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/prod/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "542450510948", 4 | "project_id": "carbon-622cf", 5 | "storage_bucket": "carbon-622cf.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:542450510948:android:24f99c77ea690b74ac8371", 11 | "android_client_info": { 12 | "package_name": "com.atomicrobot.carbon" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "542450510948-mpqi42i1dnsk88j6t9qgv1e1lnnsg3hc.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyCNZr1_ram8K4V0r32-SsRPNrQjOyp0SC0" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "542450510948-mpqi42i1dnsk88j6t9qgv1e1lnnsg3hc.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | ], 38 | "configuration_version": "1" 39 | } -------------------------------------------------------------------------------- /app/src/prod/java/com/atomicrobot/carbon/app/VariantModule.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import okhttp3.OkHttpClient 4 | import org.koin.dsl.module 5 | 6 | val variantModule = 7 | module { 8 | single { 9 | NoOpSecurityModifier() as OkHttpSecurityModifier 10 | } 11 | } 12 | 13 | class NoOpSecurityModifier : OkHttpSecurityModifier { 14 | override fun apply(builder: OkHttpClient.Builder) { 15 | // No op 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/prod/java/com/atomicrobot/carbon/app/VariantSettings.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.content.Context 4 | import com.atomicrobot.carbon.R 5 | 6 | open class VariantSettings(private val context: Context) { 7 | val baseUrl: String = context.getString(R.string.default_base_url) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/release/java/com/atomicrobot/carbon/app/MainApplicationInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.app 2 | 3 | import android.app.Application 4 | import com.atomicrobot.carbon.monitoring.model.NoOpTree 5 | 6 | /** 7 | * Specific to the production variant. 8 | */ 9 | class MainApplicationInitializer(application: Application) : BaseApplicationInitializer(application, NoOpTree()) { 10 | override fun initialize() { 11 | super.initialize() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/release/java/com/atomicrobot/carbon/modules/CrashReporterModule.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.modules 2 | 3 | import com.atomicrobot.carbon.monitoring.CrashlyticsCrashReporter 4 | import org.koin.dsl.module 5 | 6 | val crashReporterModule = 7 | module { 8 | single { 9 | CrashlyticsCrashReporter() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/release/java/com/atomicrobot/carbon/monitoring/model/NoOpTree.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.monitoring.model 2 | 3 | import timber.log.Timber 4 | 5 | class NoOpTree : Timber.Tree() { 6 | override fun log( 7 | priority: Int, 8 | tag: String?, 9 | message: String, 10 | t: Throwable?, 11 | ) { 12 | // No op 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/RxTests.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.Observable 5 | import io.reactivex.Single 6 | import io.reactivex.observers.TestObserver 7 | import org.junit.After 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.koin.core.context.stopKoin 11 | 12 | class RxTests { 13 | private lateinit var subscriber: TestObserver 14 | 15 | @Before 16 | fun setup() { 17 | subscriber = TestObserver() 18 | } 19 | 20 | @After 21 | fun teardown() { 22 | stopKoin() 23 | } 24 | 25 | @Test 26 | fun testObservableFactoryError() { 27 | Observable.error(IllegalStateException()) 28 | .subscribe(subscriber) 29 | subscriber.assertError(IllegalStateException::class.java) 30 | } 31 | 32 | @Test 33 | fun testSingleFactoryError() { 34 | Single.error(IllegalStateException()) 35 | .subscribe(subscriber) 36 | subscriber.assertError(IllegalStateException::class.java) 37 | } 38 | 39 | @Test 40 | fun testCompletableFactoryError() { 41 | Completable.error(IllegalStateException()) 42 | .subscribe(subscriber) 43 | subscriber.assertError(IllegalStateException::class.java) 44 | } 45 | 46 | @Test 47 | fun testObservableCallableError() { 48 | Observable.fromCallable { throw IllegalStateException() } 49 | .subscribe(subscriber) 50 | subscriber.assertError(IllegalStateException::class.java) 51 | } 52 | 53 | @Test 54 | fun testSingleCallableError() { 55 | Single.fromCallable { throw IllegalStateException() } 56 | .subscribe(subscriber) 57 | subscriber.assertError(IllegalStateException::class.java) 58 | } 59 | 60 | @Test 61 | fun testCompletableCallableError() { 62 | Completable.fromCallable { throw IllegalStateException() } 63 | .subscribe(subscriber) 64 | subscriber.assertError(IllegalStateException::class.java) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/SimpleRobolectricTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import android.content.Context 4 | import androidx.test.core.app.ApplicationProvider 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import org.junit.After 7 | import org.junit.Assert.assertEquals 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.koin.core.context.stopKoin 11 | 12 | @RunWith(AndroidJUnit4::class) 13 | class SimpleRobolectricTest { 14 | @After 15 | fun teardown() { 16 | stopKoin() 17 | } 18 | 19 | @Test 20 | fun testAppName() { 21 | val context = ApplicationProvider.getApplicationContext() 22 | val appName = context.getString(R.string.app_name) 23 | assertEquals("My App", appName) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/SimpleTests.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import android.os.Bundle 4 | import com.nhaarman.mockitokotlin2.whenever 5 | import org.junit.After 6 | import org.junit.Assert.assertEquals 7 | import org.junit.Assert.assertTrue 8 | import org.junit.Test 9 | import org.koin.core.context.stopKoin 10 | import org.mockito.Mockito.mock 11 | 12 | class SimpleTests { 13 | @After 14 | fun teardown() { 15 | stopKoin() 16 | } 17 | 18 | @Test 19 | fun testTrueIsTrue() { 20 | assertTrue(true) 21 | } 22 | 23 | @Test 24 | fun testMocking() { 25 | val mockBundle = mock(Bundle::class.java) 26 | whenever(mockBundle.getString("key")).thenReturn("value") 27 | 28 | val value = mockBundle.getString("key") 29 | assertEquals("value", value) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/TestExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon 2 | 3 | import okio.buffer 4 | import okio.source 5 | import java.io.File 6 | import java.nio.charset.Charset 7 | 8 | object TestExtensions 9 | 10 | @Throws(Exception::class) 11 | fun String.loadResourceAsString(): String { 12 | val url = TestExtensions::class.java.getResource(this) 13 | val file = File(url!!.file) 14 | return file.source().buffer().readString(Charset.forName("UTF-8")) 15 | } 16 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/data/api/github/DeepLinkInteractorTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import android.graphics.Color 4 | import android.net.Uri 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import com.atomicrobot.carbon.deeplink.DeepLinkInteractor 7 | import org.junit.After 8 | import org.junit.Assert.assertTrue 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.koin.core.context.stopKoin 13 | import org.mockito.MockitoAnnotations 14 | 15 | @RunWith(AndroidJUnit4::class) 16 | class DeepLinkInteractorTest { 17 | private lateinit var interactor: DeepLinkInteractor 18 | 19 | @Before 20 | fun setup() { 21 | stopKoin() 22 | MockitoAnnotations.openMocks(this) 23 | interactor = DeepLinkInteractor() 24 | } 25 | 26 | @After 27 | fun tearDown() { 28 | stopKoin() 29 | } 30 | 31 | @Test 32 | fun testDeepLinkPath() { 33 | interactor.setDeepLinkUri(Uri.parse("https://www.atomicrobot.com/carbon-android/path1")) 34 | interactor.setDeepLinkPath("/carbon-android/path1") 35 | 36 | val navResource = interactor.getDeepLinkNavDestination() 37 | assertTrue(navResource == "deepLinkPath1") 38 | } 39 | 40 | @Test 41 | fun testDeepLinkPathTextColor() { 42 | interactor.setDeepLinkUri(Uri.parse("https://www.atomicrobot.com/carbon-android/path1?textColor=blue")) 43 | 44 | val resourceColor = interactor.getDeepLinkTextColor() 45 | assertTrue(resourceColor == Color.BLUE) 46 | } 47 | 48 | @Test 49 | fun testDeepLinkPathTextSize() { 50 | interactor.setDeepLinkUri(Uri.parse("https://www.atomicrobot.com/carbon-android/path1?textSize=22")) 51 | 52 | val resourceSize = interactor.getDeepLinkTextSize() 53 | assertTrue(resourceSize == 22f) 54 | } 55 | 56 | @Test 57 | fun testDeepLinkPathTextColorTextSize() { 58 | // Passing in bad data, function should use default values 59 | interactor.setDeepLinkUri(Uri.parse("https://www.atomicrobot.com/carbon-android/path1?textColor=razzle&textSize=22L")) 60 | interactor.setDeepLinkPath("/carbon-android/path1") 61 | 62 | val navResource = interactor.getDeepLinkNavDestination() 63 | assertTrue(navResource == "deepLinkPath1") 64 | 65 | val resourceColor = interactor.getDeepLinkTextColor() 66 | assertTrue(resourceColor == Color.BLACK) 67 | 68 | val resourceSize = interactor.getDeepLinkTextSize() 69 | assertTrue(resourceSize == 30f) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/data/api/github/GitHubApiServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import com.atomicrobot.carbon.app.provideGitHubApiService 4 | import com.atomicrobot.carbon.app.provideRetrofit 5 | import com.atomicrobot.carbon.loadResourceAsString 6 | import com.squareup.moshi.Moshi 7 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 8 | import kotlinx.coroutines.runBlocking 9 | import okhttp3.OkHttpClient 10 | import okhttp3.mockwebserver.MockResponse 11 | import okhttp3.mockwebserver.MockWebServer 12 | import org.junit.After 13 | import org.junit.Assert.assertEquals 14 | import org.junit.Assert.assertFalse 15 | import org.junit.Assert.assertTrue 16 | import org.junit.Before 17 | import org.junit.Test 18 | import org.koin.core.context.stopKoin 19 | import org.mockito.MockitoAnnotations 20 | import retrofit2.Converter 21 | import retrofit2.converter.moshi.MoshiConverterFactory 22 | import java.net.UnknownHostException 23 | 24 | class GitHubApiServiceTest { 25 | private lateinit var server: MockWebServer 26 | 27 | @Before 28 | fun setup() { 29 | stopKoin() 30 | MockitoAnnotations.openMocks(this) 31 | server = MockWebServer() 32 | } 33 | 34 | @After 35 | @Throws(Exception::class) 36 | fun tearDown() { 37 | stopKoin() 38 | server.shutdown() 39 | } 40 | 41 | @Test 42 | @Throws(Exception::class) 43 | fun testListCommitsSuccessful() = 44 | runBlocking { 45 | server.enqueue( 46 | MockResponse().setBody("/api/listCommits_success.json".loadResourceAsString()), 47 | ) 48 | server.start() 49 | 50 | val api = buildApi(server) 51 | val goodResponse = api.listCommits("test_user", "test_repository") 52 | 53 | val serverRequest = server.takeRequest() 54 | assertEquals("GET", serverRequest.method) 55 | assertEquals("/repos/test_user/test_repository/commits", serverRequest.path) 56 | 57 | assertTrue(goodResponse.errorBody() == null) 58 | assertTrue(goodResponse.isSuccessful) 59 | 60 | val commits = goodResponse.body() 61 | assertEquals(1, commits!!.size.toLong()) 62 | val commit = commits[0] 63 | assertEquals("test message", commit.commitMessage) 64 | assertEquals("test author", commit.author) 65 | } 66 | 67 | @Test 68 | @Throws(Exception::class) 69 | fun testListCommitsUnsuccessful() = 70 | runBlocking { 71 | server.enqueue(MockResponse().setResponseCode(404).setBody("{\"message\": \"Not Found\"}")) 72 | server.start() 73 | 74 | val api = buildApi(server) 75 | val badResponse = api.listCommits("test_user", "test_repository") 76 | 77 | assertFalse(badResponse.isSuccessful) 78 | assertEquals(404, badResponse.code().toLong()) 79 | } 80 | 81 | @Test(expected = UnknownHostException::class) 82 | @Throws(Exception::class) 83 | fun testListCommitsNetworkError(): Unit = 84 | runBlocking { 85 | val api = buildApi("http://bad_url/") 86 | api.listCommits("test_user", "test_repository") 87 | } 88 | 89 | @Throws(Exception::class) 90 | private fun buildApi(server: MockWebServer): GitHubApiService { 91 | val baseUrl = server.url("") 92 | return buildApi(baseUrl.toString()) 93 | } 94 | 95 | @Throws(Exception::class) 96 | private fun buildApi(baseUrl: String): GitHubApiService { 97 | val client = OkHttpClient.Builder().build() 98 | // TODO - fix with koin tests? 99 | val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() 100 | val converterFactory = MoshiConverterFactory.create(moshi) as Converter.Factory 101 | // val retrofit = module.provideRetrofit(client, baseUrl, converterFactory) 102 | // return module.provideGitHubApiService(retrofit) 103 | val retrofit = provideRetrofit(client, baseUrl, converterFactory) 104 | return provideGitHubApiService(retrofit) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/data/api/github/GitHubCardApiServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import com.atomicrobot.carbon.app.provideDetailedGitHubApiService 4 | import com.atomicrobot.carbon.app.provideRetrofit 5 | import com.atomicrobot.carbon.loadResourceAsString 6 | import com.squareup.moshi.Moshi 7 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 8 | import kotlinx.coroutines.runBlocking 9 | import okhttp3.OkHttpClient 10 | import okhttp3.mockwebserver.MockResponse 11 | import okhttp3.mockwebserver.MockWebServer 12 | import org.junit.After 13 | import org.junit.Assert.assertEquals 14 | import org.junit.Assert.assertFalse 15 | import org.junit.Assert.assertTrue 16 | import org.junit.Before 17 | import org.junit.Test 18 | import org.koin.core.context.stopKoin 19 | import org.mockito.MockitoAnnotations 20 | import retrofit2.Converter 21 | import retrofit2.converter.moshi.MoshiConverterFactory 22 | import java.net.UnknownHostException 23 | 24 | internal class GitHubCardApiServiceTest { 25 | private lateinit var server: MockWebServer 26 | 27 | @Before 28 | fun setup() { 29 | stopKoin() 30 | MockitoAnnotations.openMocks(this) 31 | server = MockWebServer() 32 | } 33 | 34 | @After 35 | @Throws(Exception::class) 36 | fun tearDown() { 37 | stopKoin() 38 | server.shutdown() 39 | } 40 | 41 | @Test 42 | @Throws(Exception::class) 43 | fun testDetailedCommitSuccessful() = 44 | runBlocking { 45 | server.enqueue( 46 | MockResponse().setBody("/api/testDetailedCommit_success.json".loadResourceAsString()), 47 | ) 48 | server.start() 49 | 50 | val api = buildApi(server) 51 | 52 | val goodResponse = api.detailedCommit("test_user", "test_repository", "test_sha") 53 | 54 | val serverRequest = server.takeRequest() 55 | assertEquals("GET", serverRequest.method) 56 | assertEquals("/repos/test_user/test_repository/commits/test_sha", serverRequest.path) 57 | 58 | assertTrue(goodResponse.errorBody() == null) 59 | assertTrue(goodResponse.isSuccessful) 60 | 61 | val commit = goodResponse.body() 62 | assertEquals("test message", commit?.detailedCommitMessage) 63 | assertEquals("test author", commit?.detailedCommitAuthor) 64 | assertEquals("test/tree/url", commit?.detailedCommitTreeURL) 65 | assertEquals(true, commit?.detailedCommitVerified) 66 | } 67 | 68 | @Test 69 | @Throws(Exception::class) 70 | fun testListCommitsUnsuccessful() = 71 | runBlocking { 72 | server.enqueue(MockResponse().setResponseCode(404).setBody("{\"message\": \"Not Found\"}")) 73 | server.start() 74 | 75 | val api = buildApi(server) 76 | val badResponse = api.detailedCommit("test_user", "test_repository", "test_sha") 77 | 78 | assertFalse(badResponse.isSuccessful) 79 | assertEquals(404, badResponse.code().toLong()) 80 | } 81 | 82 | @Test(expected = UnknownHostException::class) 83 | @Throws(Exception::class) 84 | fun testListCommitsNetworkError(): Unit = 85 | runBlocking { 86 | val api = buildApi("http://bad_url/") 87 | api.detailedCommit("test_user", "test_repository", "test_sha") 88 | } 89 | 90 | @Throws(Exception::class) 91 | private fun buildApi(server: MockWebServer): DetailedGitHubApiService { 92 | val baseUrl = server.url("") 93 | return buildApi(baseUrl.toString()) 94 | } 95 | 96 | @Throws(Exception::class) 97 | private fun buildApi(baseUrl: String): DetailedGitHubApiService { 98 | val client = OkHttpClient.Builder().build() 99 | // TODO - fix with koin tests? 100 | val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() 101 | val converterFactory = MoshiConverterFactory.create(moshi) as Converter.Factory 102 | // val retrofit = module.provideRetrofit(client, baseUrl, converterFactory) 103 | // return module.provideDetailedGitHubApiService(retrofit) 104 | val retrofit = provideRetrofit(client, baseUrl, converterFactory) 105 | return provideDetailedGitHubApiService(retrofit) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/data/api/github/GitHubInteractorTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github 2 | 3 | import android.app.Application 4 | import androidx.test.core.app.ApplicationProvider 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import com.atomicrobot.carbon.data.api.github.GitHubInteractor.LoadCommitsRequest 7 | import com.atomicrobot.carbon.data.api.github.model.CommitTestHelper.stubCommit 8 | import com.nhaarman.mockitokotlin2.whenever 9 | import kotlinx.coroutines.runBlocking 10 | import org.junit.After 11 | import org.junit.Assert.assertEquals 12 | import org.junit.Assert.assertTrue 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.koin.core.context.stopKoin 17 | import org.mockito.ArgumentMatchers.anyString 18 | import org.mockito.Mock 19 | import org.mockito.MockitoAnnotations 20 | import retrofit2.Response 21 | 22 | @RunWith(AndroidJUnit4::class) 23 | class GitHubInteractorTest { 24 | @Mock lateinit var api: GitHubApiService 25 | 26 | @Mock lateinit var api2: DetailedGitHubApiService 27 | private lateinit var interactor: GitHubInteractor 28 | 29 | @Before 30 | fun setup() { 31 | stopKoin() 32 | MockitoAnnotations.openMocks(this) 33 | 34 | val context = ApplicationProvider.getApplicationContext() 35 | interactor = GitHubInteractor(context, api, api2) 36 | } 37 | 38 | @After 39 | @Throws(Exception::class) 40 | fun tearDown() { 41 | stopKoin() 42 | } 43 | 44 | @Test 45 | @Throws(Exception::class) 46 | fun testLoadCommits() = 47 | runBlocking { 48 | val mockResponse = Response.success(listOf(stubCommit("test name", "test message"))) 49 | whenever(api.listCommits(anyString(), anyString())).thenReturn(mockResponse) 50 | 51 | val response = interactor.loadCommits(LoadCommitsRequest("user", "repo")) 52 | 53 | assertTrue(mockResponse.isSuccessful) 54 | assertEquals(mockResponse, interactor.checkResponse(mockResponse, "")) 55 | assertTrue(response.commits.isNotEmpty()) 56 | 57 | assertEquals("user", response.request.user) 58 | assertEquals("repo", response.request.repository) 59 | assertEquals(1, response.commits.size.toLong()) 60 | 61 | val commit = response.commits[0] 62 | assertEquals("test name", commit.author) 63 | assertEquals("test message", commit.commitMessage) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/data/api/github/model/CommitTestHelper.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.data.api.github.model 2 | 3 | object CommitTestHelper { 4 | fun stubCommit( 5 | authorName: String, 6 | message: String, 7 | ): Commit { 8 | val author = Author(authorName, email = "example@example.com", date = "01/01/2023") 9 | val commitDetails = CommitDetails(message, author) 10 | return Commit(commitDetails, sha = "") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/test/java/com/atomicrobot/carbon/ui/main/MainViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.atomicrobot.carbon.ui.main 2 | 3 | import android.app.Application 4 | import androidx.test.core.app.ApplicationProvider 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import com.atomicrobot.carbon.data.api.github.GitHubInteractor 7 | import com.atomicrobot.carbon.data.api.github.model.Commit 8 | import com.nhaarman.mockitokotlin2.any 9 | import com.nhaarman.mockitokotlin2.whenever 10 | import kotlinx.coroutines.runBlocking 11 | import org.junit.After 12 | import org.junit.Assert.assertTrue 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.koin.core.context.stopKoin 17 | import org.mockito.Mock 18 | import org.mockito.Mockito.mock 19 | import org.mockito.MockitoAnnotations 20 | 21 | @RunWith(AndroidJUnit4::class) 22 | class MainViewModelTest { 23 | @Mock private lateinit var githubInteractor: GitHubInteractor 24 | 25 | private lateinit var viewModel: MainViewModel 26 | 27 | @Before 28 | fun setup() { 29 | stopKoin() 30 | MockitoAnnotations.openMocks(this) 31 | 32 | val app = ApplicationProvider.getApplicationContext() 33 | viewModel = 34 | MainViewModel( 35 | app, 36 | githubInteractor, 37 | 0, 38 | ) 39 | } 40 | 41 | @After 42 | @Throws(Exception::class) 43 | fun tearDown() { 44 | stopKoin() 45 | } 46 | 47 | @Test 48 | fun testFetchCommits() = 49 | runBlocking { 50 | val mockResult = mock(GitHubInteractor.LoadCommitsResponse::class.java) 51 | val mockCommit = mock(Commit::class.java) 52 | whenever(mockResult.commits).thenReturn(listOf(mockCommit)) 53 | whenever(githubInteractor.loadCommits(any())).thenReturn(mockResult) 54 | 55 | assertTrue( 56 | (viewModel.uiState.value.commitsState as? MainViewModel.Commits.Result) 57 | ?.commits?.isEmpty() 58 | ?: false, 59 | ) 60 | viewModel.fetchCommits() 61 | assertTrue( 62 | (viewModel.uiState.value.commitsState as? MainViewModel.Commits.Result) 63 | ?.commits?.size == 1, 64 | ) 65 | } 66 | 67 | @Test 68 | fun testGetVersion() { 69 | // CI systems can change the build number so we are a little more flexible on what to expect 70 | val expectedPattern = "1.0 b[1-9][0-9]*".toRegex() 71 | assertTrue("1.0 b123".matches(expectedPattern)) 72 | assertTrue(viewModel.getVersion().matches(expectedPattern)) 73 | } 74 | 75 | @Test 76 | fun testGetVersionFingerprint() { 77 | val expectedPattern = "[a-zA-Z0-9]+".toRegex() 78 | assertTrue("0569b5cd8".matches(expectedPattern)) 79 | assertTrue("DEV".matches(expectedPattern)) 80 | assertTrue(viewModel.getVersionFingerprint().matches(expectedPattern)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/test/resources/api/listCommits_success.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "sha": "test sha", 3 | "commit": { 4 | "message": "test message", 5 | "author": { 6 | "name": "test author", 7 | "email": "testemail@example.com", 8 | "date": "01/01/2023" 9 | } 10 | } 11 | }] -------------------------------------------------------------------------------- /app/src/test/resources/api/testDetailedCommit_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "sha": "test_sha", 3 | "commit": { 4 | "author": { 5 | "name": "test author", 6 | "email": "example@example.com", 7 | "date": "2023-01-11T18:06:20Z" 8 | }, 9 | "committer": { 10 | "name": "GitHub", 11 | "email": "noreply@github.com", 12 | "date": "2023-01-11T18:06:20Z" 13 | }, 14 | "message": "test message", 15 | "tree": { 16 | "sha": "429a55ba62f6a7ba778b0b8b870546fee5b7d534", 17 | "url": "test/tree/url" 18 | }, 19 | "verification": { 20 | "verified": true, 21 | "reason": "valid", 22 | "signature": "-----BEGIN PGP SIGNATURE-----\nTest Signature\n-----END PGP SIGNATURE-----\n", 23 | "payload": "test payload" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /app/src/test/resources/robolectric.properties: -------------------------------------------------------------------------------- 1 | packageName=com.atomicrobot.carbon 2 | constants=com.atomicrobot.carbon.BuildConfig 3 | sdk=35 4 | -------------------------------------------------------------------------------- /app/src/test/sample_scanner_codes/barcode ar_5155.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/test/sample_scanner_codes/barcode ar_5155.png -------------------------------------------------------------------------------- /app/src/test/sample_scanner_codes/qr-code_black_18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/test/sample_scanner_codes/qr-code_black_18.png -------------------------------------------------------------------------------- /app/src/test/sample_scanner_codes/qr-code_blue_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/test/sample_scanner_codes/qr-code_blue_48.png -------------------------------------------------------------------------------- /app/src/test/sample_scanner_codes/qr-code_green_28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/test/sample_scanner_codes/qr-code_green_28.png -------------------------------------------------------------------------------- /app/src/test/sample_scanner_codes/qr-code_red_22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/app/src/test/sample_scanner_codes/qr-code_red_22.png -------------------------------------------------------------------------------- /app/templates/screen/Activity.kt.mustache: -------------------------------------------------------------------------------- 1 | package {{package}}.ui.{{screenLowerCase}} 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.databinding.DataBindingUtil 6 | import android.os.Bundle 7 | 8 | import {{package}}.R 9 | import {{package}}.ui.BaseActivity 10 | import {{package}}.ui.{{screenLowerCase}}.{{screenUpperCamel}}Fragment.{{screenUpperCamel}}FragmentHost 11 | 12 | import javax.inject.Inject 13 | 14 | class {{screenUpperCamel}}Activity : BaseActivity(), {{screenUpperCamel}}FragmentHost { 15 | @Inject lateinit var viewModel: {{screenUpperCamel}}ViewModel 16 | private lateinit var binding: {{screenUpperCamel}}ActivityBinding 17 | 18 | public override fun onCreate(savedInstanceState: Bundle?) { 19 | appComponent.inject(this) 20 | super.onCreate(savedInstanceState) 21 | 22 | viewModel = getViewModel({{screenUpperCamel}}ViewModel::class) 23 | viewModel.restoreState(savedInstanceState) 24 | 25 | binding = DataBindingUtil.setContentView(this, R.layout.activity_{{screenUnderscore}}) 26 | binding.vm = viewModel 27 | binding.executePendingBindings() 28 | 29 | setSupportActionBar(binding.toolbar) 30 | supportActionBar!!.title = "{{screenUpperCamel}}" 31 | } 32 | 33 | override fun onSaveInstanceState(outState: Bundle) { 34 | super.onSaveInstanceState(outState) 35 | viewModel.saveState(outState) 36 | } 37 | 38 | override fun onResume() { 39 | super.onResume() 40 | viewModel.onResume() 41 | } 42 | 43 | companion object { 44 | fun buildIntent(context: Context): Intent { 45 | return Intent(context, {{screenUpperCamel}}Activity::class.java) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/templates/screen/ActivityTests.kt.mustache: -------------------------------------------------------------------------------- 1 | package {{package}}.ui.{{screenLowerCase}} 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.rule.ActivityTestRule 5 | 6 | import org.junit.Rule 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | 10 | @RunWith(AndroidJUnit4::class) 11 | class {{screenUpperCamel}}ActivityEspressoTests { 12 | 13 | @JvmField @Rule val activityRule = ActivityTestRule<{{screenUpperCamel}}Activity>({{screenUpperCamel}}Activity::class.java, false, false) 14 | 15 | @Test 16 | fun testLaunchActivity() { 17 | activityRule.launchActivity(null) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/templates/screen/AndroidManifest_ActivityPartial.xml.mustache: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/templates/screen/ApplicationComponent_ActivityPartial.kt.mustache: -------------------------------------------------------------------------------- 1 | fun inject(activity: {{screenUpperCamel}}Activity) 2 | // GENERATOR - MORE ACTIVITIES // -------------------------------------------------------------------------------- /app/templates/screen/ApplicationComponent_ImportPartial.kt.mustache: -------------------------------------------------------------------------------- 1 | import {{package}}.ui.{{screenLowerCase}}.{{screenUpperCamel}}Activity 2 | // GENERATOR - MORE IMPORTS // -------------------------------------------------------------------------------- /app/templates/screen/Fragment.kt.mustache: -------------------------------------------------------------------------------- 1 | package {{package}}.ui.{{screenLowerCase}} 2 | 3 | import android.content.Context 4 | import androidx.databinding.DataBindingUtil 5 | import android.os.Bundle 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import {{package}}.ui.BaseFragment 10 | 11 | import {{package}}.R 12 | 13 | class {{screenUpperCamel}}Fragment : BaseFragment() { 14 | interface {{screenUpperCamel}}FragmentHost 15 | 16 | private lateinit var viewModel: {{screenUpperCamel}}ViewModel 17 | private lateinit var binding: {{screenUpperCamel}}FragmentBinding 18 | private var host: {{screenUpperCamel}}FragmentHost? = null 19 | 20 | override fun onAttach(context: Context?) { 21 | super.onAttach(context) 22 | host = context as {{screenUpperCamel}}FragmentHost 23 | } 24 | 25 | override fun onDetach() { 26 | host = null 27 | super.onDetach() 28 | } 29 | 30 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 31 | viewModel = getViewModel({{screenUpperCamel}}ViewModel::class) 32 | 33 | binding = DataBindingUtil.inflate(inflater, R.layout.fragment_{{screenUnderscore}}, container, false) 34 | binding.vm = viewModel 35 | 36 | return binding.root 37 | } 38 | 39 | override fun onDestroyView() { 40 | binding.unbind() 41 | super.onDestroyView() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/templates/screen/ViewModel.kt.mustache: -------------------------------------------------------------------------------- 1 | package {{package}}.ui.{{screenLowerCase}} 2 | 3 | import android.app.Application 4 | import android.os.Parcelable 5 | import {{package}}.ui.BaseViewModel 6 | import kotlinx.android.parcel.Parcelize 7 | import javax.inject.Inject 8 | 9 | class {{screenUpperCamel}}ViewModel @Inject constructor( 10 | private val app: Application) 11 | : BaseViewModel<{{screenUpperCamel}}ViewModel.State>(app, STATE_KEY, State()) { 12 | 13 | @Parcelize 14 | class State() : Parcelable 15 | 16 | override fun setupViewModel() { 17 | 18 | } 19 | 20 | companion object { 21 | private const val STATE_KEY = "{{screenUpperCamel}}ViewModelState" // NON-NLS 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/templates/screen/ViewModelFactoryModule_ImportPartial.kt.mustache: -------------------------------------------------------------------------------- 1 | import {{package}}.ui.{{screenLowerCase}}.{{screenUpperCamel}}ViewModel 2 | // GENERATOR - MORE IMPORTS // -------------------------------------------------------------------------------- /app/templates/screen/ViewModelFactoryModule_ViewModel.kt.mustache: -------------------------------------------------------------------------------- 1 | @Binds 2 | @IntoMap 3 | @ViewModelKey({{screenUpperCamel}}ViewModel::class) 4 | abstract fun binds{{screenUpperCamel}}ViewModel({{screenLowerCamel}}ViewModel: {{screenUpperCamel}}ViewModel): ViewModel 5 | 6 | // GENERATOR - MORE VIEW MODELS // -------------------------------------------------------------------------------- /app/templates/screen/ViewModelTests.kt.mustache: -------------------------------------------------------------------------------- 1 | package {{package}}.ui.{{screenLowerCase}} 2 | 3 | import android.app.Application 4 | import androidx.test.core.app.ApplicationProvider 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import org.junit.Assert 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.mockito.MockitoAnnotations 11 | 12 | @RunWith(AndroidJUnit4::class) 13 | class {{screenUpperCamel}}ViewModelTests { 14 | 15 | private lateinit var viewModel: {{screenUpperCamel}}ViewModel 16 | 17 | @Before 18 | fun setup() { 19 | MockitoAnnotations.initMocks(this) 20 | 21 | val app = ApplicationProvider.getApplicationContext() 22 | viewModel = {{screenUpperCamel}}ViewModel(app) 23 | } 24 | 25 | @Test 26 | fun testTrueIsTrue() { 27 | // This is lame...replace with something useful! 28 | Assert.assertTrue(true) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/templates/screen/activity.xml.mustache: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 11 | 16 | 17 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/templates/screen/content.xml.mustache: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/screen/fragment.xml.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) apply false 3 | alias(libs.plugins.firebase.crashlytics) apply false 4 | alias(libs.plugins.google.services) apply false 5 | alias(libs.plugins.kotlin.allopen) apply false 6 | alias(libs.plugins.kotlin.android) apply false 7 | alias(libs.plugins.kotlin.parcelize) apply false 8 | alias(libs.plugins.ksp) apply false 9 | alias(libs.plugins.ktlint) apply false 10 | jacoco 11 | java 12 | idea 13 | } 14 | 15 | idea { 16 | module { 17 | isDownloadJavadoc = true 18 | isDownloadSources = true 19 | } 20 | } 21 | 22 | allprojects { 23 | // Verbose output for usage of deprecated APIs 24 | tasks.withType { 25 | options.compilerArgs = mutableListOf("-Xlint:deprecation") 26 | } 27 | 28 | // Prevent wildcard dependencies 29 | // Code in groovy below 30 | // https://gist.github.com/JakeWharton/2066f5e4f08fbaaa68fd 31 | // modified Wharton's code for kts 32 | afterEvaluate() { 33 | project.configurations.all { 34 | resolutionStrategy.eachDependency { 35 | if (requested.version!!.contains("+")) { 36 | throw GradleException("Wildcard dependency forbidden: ${requested.group}:" + 37 | "${requested.name}:${requested.version}") 38 | } 39 | } 40 | } 41 | } 42 | 43 | // Apply sample.gradle with project ext values 44 | apply(rootProject.file("distribution/keys/sample.gradle")) 45 | } 46 | 47 | evaluationDependsOnChildren() 48 | 49 | val initialCleanup by tasks.registering { 50 | val cleanTasks = getProjectTask(rootProject, "clean") 51 | val uninstallTasks = getProjectTask(rootProject, "uninstallAll") 52 | dependsOn(cleanTasks) 53 | dependsOn(uninstallTasks) 54 | } 55 | 56 | val testing by tasks.registering { 57 | val appProject = subprojects.find { project -> "app" == project.name } 58 | 59 | val unitTestTasks = getProjectTask(appProject!!, "testDevDebugUnitTest") 60 | val integrationTestTasks = getProjectTask(appProject, "jacocoTestReport") 61 | 62 | dependsOn(unitTestTasks) 63 | dependsOn(integrationTestTasks) 64 | 65 | integrationTestTasks.forEach { task -> task.mustRunAfter(unitTestTasks) } 66 | } 67 | 68 | val release by tasks.registering { 69 | val appProject = subprojects.find { project -> "app" == project.name } 70 | 71 | val appTasks = getProjectTask(appProject!!, "assemble") 72 | 73 | dependsOn(appTasks) 74 | } 75 | 76 | release { 77 | mustRunAfter(testing) 78 | } 79 | 80 | fun getProjectTask(project: Project, taskName: String): MutableSet { 81 | val tasks = project.getTasksByName(taskName, true) 82 | if (tasks.isEmpty()) { 83 | throw IllegalArgumentException("Task $taskName not found") 84 | } 85 | return tasks 86 | } 87 | 88 | val continuousIntegration by tasks.registering { 89 | dependsOn(initialCleanup) 90 | dependsOn(testing) 91 | dependsOn(release) 92 | } -------------------------------------------------------------------------------- /distribution/keys/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/distribution/keys/debug.keystore -------------------------------------------------------------------------------- /distribution/keys/generateKey.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Generates a key suitable for signing an Android app. You need to provide 4 | # a name and will be prompted with a bunch of certificate questions. You'll 5 | # get a keystore and gradle file with the passwords IN PLAIN TEXT. 6 | # 7 | # Usage: generateKey.sh name 8 | # 9 | function generateRandomKey() 10 | { 11 | local randomKey=`env LC_CTYPE=C tr -dc "a-zA-Z0-9-_\$\?" < /dev/urandom | head -c 64` 12 | echo "$randomKey" 13 | } 14 | 15 | function generateKey() 16 | { 17 | local name=$1 18 | local keystorePassword=$2 19 | local aliasPassword=$3 20 | keytool -genkey -v -keystore ${keystoreFile} -alias ${name} -keyalg RSA -keysize 2048 -validity 10000 -storepass ${keystorePassword} -keypass ${aliasPassword} 21 | } 22 | 23 | name=${1} 24 | keystorePassword=$(generateRandomKey) 25 | aliasPassword=$(generateRandomKey) 26 | keystoreFile=${name}.jks 27 | gradleFile=${name}.gradle 28 | 29 | generateKey ${name} ${keystorePassword} ${aliasPassword} 30 | 31 | touch ${gradleFile} 32 | echo " 33 | project.ext { 34 | ${name}Keystore = 'distribution/keys/$keystoreFile' 35 | ${name}KeystorePassword = '$keystorePassword' 36 | ${name}KeyAlias = '$name' 37 | ${name}KeyPassword = '$aliasPassword' 38 | }" >> ${gradleFile} -------------------------------------------------------------------------------- /distribution/keys/sample.gradle: -------------------------------------------------------------------------------- 1 | 2 | project.ext { 3 | sampleKeystore = 'distribution/keys/sample.jks' 4 | sampleKeystorePassword = 'Z-LEfATdD3AlTxeVUCEEeN--vQ3yU00SEz0t1C81WNQVXeJlFSCV8RiPr8UM7oQa' 5 | sampleKeyAlias = 'sample' 6 | sampleKeyPassword = 'irKlcFZIhqjWCXkuT3jpGBpB6ym730C$FVMxySSfahgmuIgq03$JLc-a6vilpQ0j' 7 | } 8 | -------------------------------------------------------------------------------- /distribution/keys/sample.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/distribution/keys/sample.jks -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx8192m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 2 | 3 | org.gradle.daemon=true 4 | org.gradle.configureondemand=false 5 | org.gradle.parallel=true 6 | 7 | org.gradle.caching=true 8 | 9 | org.gradle.warning.mode=summary 10 | android.useAndroidX=true 11 | android.enableJetifier=true 12 | 13 | android.jetifier.ignorelist=bcprov-jdk15on -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicrobot/Carbon-Android/448fde3cbab488bcbf04ab8c32f66ff204572d6a/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.9-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 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /renamePackage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Usage renamePackage.py package 4 | # Ex: renamePackage.py com.demo.mobile 5 | 6 | import os, sys 7 | import shutil 8 | import platform 9 | from functools import reduce 10 | 11 | stuffToRemove = [".gradle", ".git", ".idea", "build", "app/build", ".iml", "local.properties"] 12 | dirChar = os.sep 13 | args = sys.argv 14 | if (len(args) != 2): 15 | print("please enter a new package name") 16 | exit() 17 | 18 | new_package = args[1] 19 | original_package = "com.atomicrobot.carbon" 20 | new_package_directory = dirChar + new_package.lower().replace('.', dirChar) + dirChar 21 | original_package_directory = dirChar + original_package.lower().replace('.', dirChar) + dirChar 22 | 23 | #deletes files and folders 24 | def nuke(folders): 25 | for f in folders: 26 | f = f.replace('/', dirChar)#Make sure it has the correct dir separator 27 | print('Removing ' + f) 28 | try: 29 | if (platform.system() == 'Windows'): 30 | os.system('rmdir /s /q ' + f) 31 | else: 32 | os.system('rm -rf ' + f) 33 | except: 34 | None 35 | 36 | return 37 | 38 | def refactorPackagenameInFile(file,oldPackageName, newPackageName): 39 | #only refactor these files 40 | if (file.endswith(".java") or file.endswith(".kt") or file.endswith(".xml") or file.endswith(".properties") or file.endswith(".txt") or file.endswith(".gradle")): 41 | f = open(file, 'r') 42 | contents = f.read() 43 | f.close() 44 | 45 | refactored = contents.replace(oldPackageName, newPackageName) 46 | f = open(file, 'w') 47 | f.write(refactored) 48 | f.close() 49 | return 50 | 51 | def refactorAllFolders(): 52 | for root, dir, files in os.walk('app'): 53 | for f in files: 54 | fpath = os.path.join(root, f) 55 | if original_package_directory in fpath: 56 | oldPath = fpath 57 | newPath = fpath.replace(original_package_directory,new_package_directory) 58 | try:#attempt to make the new package directory incase it doesn't exist 59 | os.makedirs((root+dirChar).replace(original_package_directory, new_package_directory)) 60 | except: 61 | None 62 | shutil.copy(oldPath, newPath)#copy the file to the new path 63 | refactorPackagenameInFile(newPath, original_package, new_package) 64 | else: 65 | refactorPackagenameInFile(fpath, original_package, new_package) 66 | 67 | for root, dir, files in os.walk('app/src'): 68 | #only use the first iteration, we just want the immidate children of this folder 69 | for folder in dir: 70 | folderpath = 'app' + dirChar + 'src' + dirChar + folder + dirChar + 'java' + dirChar + 'com' + dirChar + 'atomicrobot' 71 | shutil.rmtree(folderpath) 72 | break 73 | 74 | nuke(stuffToRemove) 75 | refactorAllFolders() 76 | 77 | os.system('git init') 78 | os.system('git add .') 79 | os.system('git commit -q -m "Initial import from github.com/atomicrobot/Carbon-Android"') 80 | 81 | print('all done :)') 82 | os.system('git log --oneline') 83 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | maven { url = uri("https://jitpack.io") } 15 | } 16 | } 17 | 18 | include(":app") 19 | -------------------------------------------------------------------------------- /testRenamePackage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd .. 4 | rm -rf android-starter-project-copy 5 | cp -R android-starter-project android-starter-project-copy 6 | cd android-starter-project-copy 7 | python renamePackage.py com.demo.mobile 8 | grep -IR 'carbon' . | grep gradle 9 | find . -name 'carbon' | grep gradle --------------------------------------------------------------------------------