├── .github └── workflows │ └── pre-workflow.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── encodings.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── gts │ │ └── trackmypath │ │ └── MainActivityTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── gts │ │ │ └── trackmypath │ │ │ ├── AppModule.kt │ │ │ ├── TrackMyPathApplication.kt │ │ │ ├── common │ │ │ └── Result.kt │ │ │ ├── data │ │ │ ├── PhotoRepositoryImpl.kt │ │ │ ├── database │ │ │ │ ├── PhotoDao.kt │ │ │ │ ├── PhotoDatabase.kt │ │ │ │ └── PhotoEntity.kt │ │ │ └── network │ │ │ │ ├── FlickrApi.kt │ │ │ │ ├── FlickrClient.kt │ │ │ │ ├── FlickrClientImpl.kt │ │ │ │ └── ResponseEntity.kt │ │ │ ├── domain │ │ │ ├── LocationServiceInteractor.kt │ │ │ ├── PhotoRepository.kt │ │ │ ├── model │ │ │ │ └── Photo.kt │ │ │ └── usecase │ │ │ │ ├── ClearPhotosUseCase.kt │ │ │ │ ├── RetrievePhotosUseCase.kt │ │ │ │ └── SearchPhotoByLocationUseCase.kt │ │ │ └── presentation │ │ │ ├── MainActivity.kt │ │ │ ├── PhotoAdapter.kt │ │ │ ├── PhotoStreamFragment.kt │ │ │ ├── PhotoStreamViewModel.kt │ │ │ ├── Utils.kt │ │ │ ├── model │ │ │ └── PhotoViewItem.kt │ │ │ └── service │ │ │ └── LocationService.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── fragment_photo_stream.xml │ │ └── photo_item.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── gts │ └── trackmypath │ ├── data │ └── repository │ │ └── PhotoRepositoryImplTest.kt │ ├── domain │ ├── RetrievePhotosUseCaseTest.kt │ └── SearchPhotoByLocationUseCaseTest.kt │ └── presentation │ ├── LiveDataTestUtil.kt │ ├── PhotoStreamViewModelTest.kt │ └── UtilsTest.kt ├── build.gradle ├── config └── detekt │ └── detekt.yml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── scrn1.png └── summary.png └── settings.gradle /.github/workflows/pre-workflow.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: 12 | - '*' 13 | 14 | # Allows you to run this workflow manually from the Actions tab 15 | workflow_dispatch: 16 | 17 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 18 | jobs: 19 | # This workflow contains a single job called "build" 20 | build: 21 | # The type of runner that the job will run on 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Clone Repo 26 | uses: actions/checkout@v2 27 | 28 | - name: set up JDK 1.8 29 | uses: actions/setup-java@v1 30 | with: 31 | java-version: 1.8 32 | 33 | # Execute Android Lint 34 | - name: Android Lint 35 | if: ${{ github.event_name == 'pull_request'}} 36 | run: ./gradlew lintDebug 37 | 38 | # Execute unit tests 39 | - name: Unit Test 40 | run: ./gradlew testDebugUnitTest 41 | 42 | # - name: Android Test Report 43 | # uses: gs-ts/android-test-report-action@v1.2.0 44 | # if: ${{ always() }} # IMPORTANT: run Android Test Report regardless 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | xmlns:android 30 | 31 | ^$ 32 | 33 | 34 | 35 |
36 |
37 | 38 | 39 | 40 | xmlns:.* 41 | 42 | ^$ 43 | 44 | 45 | BY_NAME 46 | 47 |
48 |
49 | 50 | 51 | 52 | .*:id 53 | 54 | http://schemas.android.com/apk/res/android 55 | 56 | 57 | 58 |
59 |
60 | 61 | 62 | 63 | .*:name 64 | 65 | http://schemas.android.com/apk/res/android 66 | 67 | 68 | 69 |
70 |
71 | 72 | 73 | 74 | name 75 | 76 | ^$ 77 | 78 | 79 | 80 |
81 |
82 | 83 | 84 | 85 | style 86 | 87 | ^$ 88 | 89 | 90 | 91 |
92 |
93 | 94 | 95 | 96 | .* 97 | 98 | ^$ 99 | 100 | 101 | BY_NAME 102 | 103 |
104 |
105 | 106 | 107 | 108 | .* 109 | 110 | http://schemas.android.com/apk/res/android 111 | 112 | 113 | ANDROID_ATTRIBUTE_ORDER 114 | 115 |
116 |
117 | 118 | 119 | 120 | .* 121 | 122 | .* 123 | 124 | 125 | BY_NAME 126 | 127 |
128 |
129 |
130 |
131 | 132 | 134 |
135 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Track my path 4 | 5 | An android app that tracks your walk with images every 100 meters: 6 | - images fetched from Flickr based on location 7 | - pictures are shown in a list, and user can scroll through the stream 8 | - one button start/stop, on each start the previous stream of photos gets wiped 9 | - when the app is removed from background and user has not stopped the tracking, the tracking continues in a service 10 | 11 | *Please create a Flickr account and use your own api key. Add it in the FlickrApi file.* 12 | 13 | --- 14 | 15 | ### MVVM pattern with Clean architecture developed with Kotlin. 16 | Clean architecture consists of three layers: 17 | - **Data**, includes data objects, databases, network clients, repositories. 18 | - **Domain**, includes use cases of business logic. This layer orchestrates the flow of data from Data Layer to Presentation and the other way. 19 | - **Presentation**, includes UI related components, such as ViewModels, Fragments, Activities. 20 | 21 | ##### Android Jetpack Components used: 22 | - Fragment 23 | - ViewModel 24 | - [LifecycleService](https://developer.android.com/reference/androidx/lifecycle/LifecycleService) 25 | - View Binding 26 | - LiveData 27 | - Room 28 | - [Location](https://github.com/googlesamples/android-play-location/tree/master/LocationUpdatesForegroundService) 29 | - [ActivityScenario](https://developer.android.com/guide/components/activities/testing), instrumentation testing (part of AndroidX Test) 30 | - Espresso (UI tests) 31 | 32 | ##### Libraries: 33 | - [Koin](https://insert-koin.io/), (in master branch) an easy-to-use DI framework. [Nice comparison with Dagger](https://medium.com/@farshidabazari/android-koin-with-mvvm-and-retrofit-e040e4e15f9d) 34 | - [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) (in [feature-hilt-di branch](https://github.com/gs-ts/TrackMyPath/tree/feature-hilt-di)) a DI library for Android based on Dagger 35 | - [Kotlin Coroutines](https://developer.android.com/kotlin/coroutines) 36 | - [fresco](https://github.com/facebook/fresco), an Android library for managing images and the memory they use 37 | - [Retrofit](https://square.github.io/retrofit/) 38 | - [OkHttp](https://square.github.io/okhttp/) 39 | - [moshi](https://github.com/square/moshi), JSON library for Kotlin and Java 40 | - [Timber](https://github.com/JakeWharton/timber), a logger which provides utility on top of Android’s Log class 41 | - [detekt](https://github.com/detekt/detekt), Static code analysis for Kotlin 42 | 43 | ##### Flickr API: 44 | - [flickr.photos.search](https://www.flickr.com/services/api/flickr.photos.search.html) 45 | 46 | Sources: 47 | - [Google I/O 2018 app — Architecture and Testing](https://medium.com/androiddevelopers/google-i-o-2018-app-architecture-and-testing-f546e37fc7eb) 48 | - [Clean Architecture of Android Apps with Practical Examples](https://rubygarage.org/blog/clean-android-architecture) 49 | - [Clean Architecture Guide (with tested examples): Data Flow != Dependency Rule](https://proandroiddev.com/clean-architecture-data-flow-dependency-rule-615ffdd79e29) 50 | 51 | ---- 52 | 53 | ### Screenshots 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-parcelize' 5 | apply plugin: 'io.gitlab.arturbosch.detekt' 6 | 7 | android { 8 | compileSdkVersion 30 9 | 10 | defaultConfig { 11 | applicationId "com.gts.trackmypath" 12 | minSdkVersion 23 13 | targetSdkVersion 30 14 | versionCode 1 15 | versionName "1.0" 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | 26 | compileOptions { 27 | sourceCompatibility JavaVersion.VERSION_1_8 28 | targetCompatibility JavaVersion.VERSION_1_8 29 | } 30 | 31 | buildFeatures { 32 | viewBinding = true 33 | } 34 | 35 | } 36 | 37 | dependencies { 38 | implementation fileTree(dir: 'libs', include: ['*.jar']) 39 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 40 | 41 | implementation "androidx.appcompat:appcompat:$appcompat_version" 42 | implementation "androidx.core:core-ktx:$ktx_version" 43 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" 44 | implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" 45 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" 46 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" 47 | implementation "androidx.constraintlayout:constraintlayout:$constraintLayout_version" 48 | implementation "androidx.localbroadcastmanager:localbroadcastmanager:$localBroadcastManager_version" 49 | implementation "androidx.preference:preference-ktx:$preference_ktx_version" 50 | implementation "androidx.room:room-ktx:$room_version" 51 | implementation "androidx.room:room-runtime:$room_version" 52 | kapt "androidx.room:room-compiler:$room_version" 53 | implementation "com.facebook.fresco:fresco:$fresco_version" 54 | 55 | implementation "com.google.android.material:material:$material_version" 56 | implementation "com.google.android.gms:play-services-location:$playServicesLocation_version" 57 | 58 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" 59 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" 60 | 61 | implementation "org.koin:koin-android:$koin_version" 62 | implementation "org.koin:koin-androidx-viewmodel:$koin_version" 63 | 64 | implementation "com.squareup.okhttp3:logging-interceptor:$okHttp_version" 65 | implementation "com.squareup.retrofit2:retrofit:$retrofit_version" 66 | implementation "com.squareup.retrofit2:converter-moshi:$moshiConverter_version" 67 | implementation "com.squareup.moshi:moshi-kotlin:$moshi_version" 68 | 69 | implementation "com.jakewharton.timber:timber:$timber_version" 70 | 71 | testImplementation 'junit:junit:4.13.1' 72 | testImplementation 'androidx.test:core:1.3.0' 73 | testImplementation 'androidx.arch.core:core-testing:2.1.0' 74 | testImplementation 'org.mockito:mockito-core:3.6.0' 75 | testImplementation 'org.mockito:mockito-inline:3.5.13' 76 | testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0' 77 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.9' 78 | 79 | androidTestImplementation 'androidx.test:rules:1.3.0' 80 | androidTestImplementation 'androidx.test:runner:1.3.0' 81 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 82 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 83 | } 84 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/gts/trackmypath/MainActivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath 2 | 3 | import org.junit.Test 4 | import org.junit.runner.RunWith 5 | 6 | import androidx.test.core.app.ActivityScenario 7 | import androidx.test.ext.junit.runners.AndroidJUnit4 8 | import androidx.test.espresso.Espresso.onView 9 | import androidx.test.espresso.matcher.ViewMatchers 10 | import androidx.test.espresso.assertion.ViewAssertions 11 | 12 | import com.gts.trackmypath.presentation.MainActivity 13 | 14 | @RunWith(AndroidJUnit4::class) 15 | class MainActivityTest { 16 | 17 | @Test 18 | fun whenLaunchApp_ThenShowRightView() { 19 | ActivityScenario.launch(MainActivity::class.java) 20 | 21 | onView(ViewMatchers.withId(R.id.buttonStart)).check( 22 | ViewAssertions.matches( 23 | ViewMatchers.isDisplayed() 24 | ) 25 | ) 26 | 27 | onView(ViewMatchers.withId(R.id.imageRecyclerView)).check( 28 | ViewAssertions.matches( 29 | ViewMatchers.isDisplayed() 30 | ) 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath 2 | 3 | import androidx.room.Room 4 | 5 | import org.koin.dsl.module 6 | import org.koin.androidx.viewmodel.dsl.viewModel 7 | import org.koin.android.ext.koin.androidApplication 8 | 9 | import okhttp3.OkHttpClient 10 | import okhttp3.logging.HttpLoggingInterceptor 11 | 12 | import retrofit2.Retrofit 13 | import retrofit2.converter.moshi.MoshiConverterFactory 14 | 15 | import com.squareup.moshi.Moshi 16 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 17 | 18 | import com.gts.trackmypath.data.network.FlickrApi 19 | import com.gts.trackmypath.data.network.FlickrClient 20 | import com.gts.trackmypath.data.network.FlickrClientImpl 21 | import com.gts.trackmypath.data.PhotoRepositoryImpl 22 | import com.gts.trackmypath.data.database.PhotoDatabase 23 | import com.gts.trackmypath.domain.LocationServiceInteractor 24 | import com.gts.trackmypath.domain.PhotoRepository 25 | import com.gts.trackmypath.domain.usecase.ClearPhotosUseCase 26 | import com.gts.trackmypath.domain.usecase.RetrievePhotosUseCase 27 | import com.gts.trackmypath.domain.usecase.SearchPhotoByLocationUseCase 28 | import com.gts.trackmypath.presentation.PhotoStreamViewModel 29 | 30 | // declare a module 31 | val appModule = module { 32 | // Define single instance of Retrofit 33 | single { provideFlickrApi().create(FlickrApi::class.java) } 34 | // Define single instance of RoomDatabase.Builder 35 | // RoomDatabase.Builder for a persistent database 36 | // Once a database is built, you should keep a reference to it and re-use it 37 | single { Room.databaseBuilder(androidApplication(), PhotoDatabase::class.java, "photo-db").build() } 38 | // Define single instance of PhotoDatabase 39 | single { get().photoDao() } 40 | // Define single instance of type FlickrClient 41 | // Resolve constructor dependencies with get(), here we need a flickrApi 42 | single { FlickrClientImpl(flickrApi = get()) } 43 | // Define single instance of type PhotoRepository 44 | // Resolve constructor dependencies with get(), here we need a flickrApi and photoDao 45 | single { 46 | PhotoRepositoryImpl(flickrClient = get(), photoDao = get()) 47 | } 48 | // Define single instance of SearchPhotoByLocationUseCase 49 | // Resolve constructor dependencies with get(), here we need a photoRepository 50 | single { 51 | SearchPhotoByLocationUseCase( 52 | photoRepository = get() 53 | ) 54 | } 55 | // Define single instance of ClearPhotosUseCase 56 | // Resolve constructor dependencies with get(), here we need a photoRepository 57 | single { 58 | ClearPhotosUseCase( 59 | photoRepository = get() 60 | ) 61 | } 62 | // Define single instance of RetrievePhotosUseCase 63 | // Resolve constructor dependencies with get(), here we need a photoRepository 64 | single { 65 | RetrievePhotosUseCase( 66 | photoRepository = get() 67 | ) 68 | } 69 | // Define single instance of LocationServiceInteractor 70 | // Resolve constructor dependencies with get(), here we need a ClearPhotosUseCase, 71 | // and a SearchPhotoByLocationUseCase 72 | single { 73 | LocationServiceInteractor( 74 | clearPhotosUseCase = get(), 75 | searchPhotoByLocationUseCase = get() 76 | ) 77 | } 78 | // Define ViewModel and resolve constructor dependencies with get(), 79 | // here we need retrievePhotosUseCase 80 | viewModel { PhotoStreamViewModel(retrievePhotosUseCase = get()) } 81 | } 82 | 83 | private val okHttpClient = OkHttpClient.Builder() 84 | .addInterceptor(run { 85 | val httpLoggingInterceptor = HttpLoggingInterceptor() 86 | httpLoggingInterceptor.apply { 87 | httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY 88 | } 89 | }) 90 | .build() 91 | 92 | private val jsonMoshi = Moshi.Builder() 93 | .add(KotlinJsonAdapterFactory()) 94 | .build() 95 | 96 | private fun provideFlickrApi(): Retrofit { 97 | return Retrofit.Builder() 98 | .addConverterFactory(MoshiConverterFactory.create(jsonMoshi)) 99 | .baseUrl("https://api.flickr.com/") 100 | .client(okHttpClient) 101 | .build() 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/TrackMyPathApplication.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath 2 | 3 | import android.app.Application 4 | import com.facebook.drawee.backends.pipeline.Fresco 5 | 6 | import org.koin.core.context.startKoin 7 | import org.koin.android.ext.koin.androidLogger 8 | import org.koin.android.ext.koin.androidContext 9 | import org.koin.core.logger.Level 10 | 11 | import timber.log.Timber 12 | 13 | class TrackMyPathApplication : Application() { 14 | override fun onCreate() { 15 | super.onCreate() 16 | Fresco.initialize(this) 17 | 18 | if (BuildConfig.DEBUG) { 19 | Timber.plant(Timber.DebugTree()) 20 | } 21 | 22 | startKoin { 23 | // use AndroidLogger as Koin Logger - default Level.INFO 24 | androidLogger(Level.ERROR) //TODO: remove Level.ERROR 25 | // use the Android context given there 26 | androidContext(this@TrackMyPathApplication) 27 | // module list 28 | modules(listOf(appModule)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/common/Result.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.common 2 | 3 | /** 4 | * A generic class that holds a value with its loading status. 5 | * @param 6 | */ 7 | sealed class Result { 8 | 9 | data class Success(val data: T) : Result() 10 | data class Error(val exception: Exception) : Result() 11 | 12 | override fun toString(): String { 13 | return when (this) { 14 | is Success<*> -> "Success[data=$data]" 15 | is Error -> "Error[exception=$exception]" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/PhotoRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data 2 | 3 | import java.io.IOException 4 | import java.lang.Exception 5 | 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | 9 | import com.gts.trackmypath.common.Result 10 | import com.gts.trackmypath.data.database.PhotoDao 11 | import com.gts.trackmypath.data.database.PhotoEntity 12 | import com.gts.trackmypath.data.database.toDomainModel 13 | import com.gts.trackmypath.data.network.toDomainModel 14 | import com.gts.trackmypath.data.network.toPhotoEntity 15 | import com.gts.trackmypath.data.network.FlickrClient 16 | import com.gts.trackmypath.data.network.PhotoResponseEntity 17 | import com.gts.trackmypath.domain.model.Photo 18 | import com.gts.trackmypath.domain.PhotoRepository 19 | 20 | import timber.log.Timber 21 | 22 | class PhotoRepositoryImpl( 23 | private val flickrClient: FlickrClient, 24 | private val photoDao: PhotoDao 25 | ) : PhotoRepository { 26 | 27 | override suspend fun searchPhotoByLocation(lat: String, lon: String): Result { 28 | return try { 29 | // Radius used for geo queries, greater than zero and less than 20 miles (or 32 kilometers), 30 | // for use with point-based geo queries. The default value is 5 (km). 31 | // Set a radius of 100 meters. (default unit is km) 32 | return when (val response = flickrClient.searchPhoto(lat, lon, "0.1")) { 33 | is Result.Success -> { 34 | val photosFromDb = photoDao.selectAllPhotos() 35 | // if photo exists in DB, then take the next from the response 36 | val photoFromFlickr = findUniquePhoto(response.data, photosFromDb) 37 | photoFromFlickr?.let { 38 | // save it in the DB 39 | photoDao.insert(photoFromFlickr.toPhotoEntity()) 40 | Result.Success(photoFromFlickr.toDomainModel()) 41 | } ?: throw IOException("no photos retrieved from flickr") 42 | } 43 | is Result.Error -> { 44 | Result.Error(response.exception) 45 | } 46 | else -> Result.Error(Exception("unknown exception")) 47 | } 48 | } catch (e: Exception) { 49 | Timber.e(e, "searchPhotoByLocation exception") 50 | Result.Error(IOException("searchPhotoByLocation exception", e)) 51 | } 52 | } 53 | 54 | // Retrieve all photos from the DB 55 | override suspend fun loadAllPhotos(): Result> { 56 | val photos = photoDao.selectAllPhotos() 57 | return if (photos.isNotEmpty()) { 58 | val result = photos.map { photoEntity -> photoEntity.toDomainModel() } 59 | Result.Success(result) 60 | } else { 61 | Result.Error(IOException("Failed to retrieve photos from database")) 62 | } 63 | } 64 | 65 | // Delete all photos in DB 66 | override suspend fun deletePhotos() { 67 | try { 68 | photoDao.deletePhotos() 69 | } catch (e: Exception) { 70 | Timber.e(e, "deletePhotos exception") 71 | } 72 | } 73 | 74 | private suspend fun findUniquePhoto( 75 | photosFromFlickr: List, 76 | photosFromDb: Array 77 | ): PhotoResponseEntity? { 78 | return withContext(Dispatchers.Default) { 79 | if (photosFromDb.isNotEmpty()) { 80 | val iterator = photosFromFlickr.iterator() 81 | while (iterator.hasNext()) { 82 | val photoResponseEntity = iterator.next() 83 | Timber.d("fetched photo id ${photoResponseEntity.id}") 84 | if (photosFromDb.all { photoEntity -> photoEntity.id != photoResponseEntity.id }) { 85 | return@withContext photoResponseEntity 86 | } 87 | } 88 | } else { 89 | return@withContext photosFromFlickr[0] 90 | } 91 | return@withContext null 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/database/PhotoDao.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.database 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Query 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | 8 | // Data Access Objects (DAO) are the main classes where you define your database interactions. 9 | // They can include a variety of query methods. 10 | @Dao 11 | interface PhotoDao { 12 | @Insert(onConflict = OnConflictStrategy.IGNORE) 13 | suspend fun insert(photo: PhotoEntity) 14 | 15 | @Query("SELECT * FROM photos ORDER BY photoId DESC") 16 | suspend fun selectAllPhotos(): Array 17 | 18 | @Query("DELETE FROM photos") 19 | suspend fun deletePhotos() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/database/PhotoDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | 6 | @Database(entities = [PhotoEntity::class], version = 1 ) 7 | abstract class PhotoDatabase : RoomDatabase() { 8 | 9 | abstract fun photoDao(): PhotoDao 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/database/PhotoEntity.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.database 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import androidx.room.ColumnInfo 6 | 7 | import com.gts.trackmypath.domain.model.Photo 8 | 9 | @Entity(tableName = "photos") 10 | class PhotoEntity( 11 | @PrimaryKey(autoGenerate = true) var photoId: Int, 12 | @ColumnInfo(name = "id") var id: String = "", 13 | @ColumnInfo(name = "secret") var secret: String = "", 14 | @ColumnInfo(name = "server") var server: String = "", 15 | @ColumnInfo(name = "farm") var farm: String = "" 16 | ) 17 | 18 | fun PhotoEntity.toDomainModel() = Photo( 19 | id = id, 20 | secret = secret, 21 | server = server, 22 | farm = farm 23 | ) 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/network/FlickrApi.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.network 2 | 3 | import retrofit2.Response 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | 7 | internal const val URLS = "url_sq, url_t, url_s, url_q, url_m, url_n, url_z, url_c, url_l, url_o" 8 | 9 | interface FlickrApi { 10 | 11 | @GET("$ENDPOINT$METHOD_PHOTOS_SEARCH$EXTRA_PARAMS") 12 | suspend fun search( 13 | @Query("api_key") apiKey: String, 14 | @Query("lat") lat: String? = null, 15 | @Query("lon") lon: String? = null, 16 | @Query("radius ") radius: String? = null, 17 | @Query("extras") extras: String = URLS 18 | ): Response 19 | 20 | companion object { 21 | const val API_KEY = "58cace78ed8d64e8490e5e3341a96930" 22 | private const val ENDPOINT = "https://www.flickr.com/services/rest/" 23 | private const val METHOD_PHOTOS_SEARCH = "?method=flickr.photos.search" 24 | private const val EXTRA_PARAMS = "&nojsoncallback=1&format=json" 25 | private const val EXTRA_SMALL_URL = "url_s" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/network/FlickrClient.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.network 2 | 3 | import com.gts.trackmypath.common.Result 4 | 5 | interface FlickrClient { 6 | 7 | suspend fun searchPhoto( 8 | lat: String, 9 | lon: String, 10 | radius: String 11 | ): Result> 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/network/FlickrClientImpl.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.network 2 | 3 | import java.io.IOException 4 | import java.lang.Exception 5 | 6 | import com.gts.trackmypath.common.Result 7 | 8 | import timber.log.Timber 9 | 10 | class FlickrClientImpl(private val flickrApi: FlickrApi) : FlickrClient { 11 | 12 | // request a photo from flickr service based on current location 13 | override suspend fun searchPhoto( 14 | lat: String, 15 | lon: String, 16 | radius: String 17 | ): Result> { 18 | return try { 19 | val response = flickrApi.search(FlickrApi.API_KEY, lat, lon, radius) 20 | if (response.isSuccessful) { 21 | val data = response.body() 22 | if (data != null) { 23 | // return the first result from response 24 | return Result.Success(data.photosResponseEntity.list) 25 | } else { 26 | Timber.e("searchPhotoByLocation data error") 27 | Result.Error(IOException("searchPhotoByLocation data error")) 28 | } 29 | } else { 30 | Timber.e("searchPhotoByLocation response error") 31 | Result.Error(IOException("searchPhotoByLocation response error")) 32 | } 33 | } catch (e: Exception) { 34 | Timber.e(e, "searchPhotoByLocation exception") 35 | Result.Error(IOException("searchPhotoByLocation exception", e)) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/data/network/ResponseEntity.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.network 2 | 3 | import com.squareup.moshi.Json 4 | 5 | import com.gts.trackmypath.data.database.PhotoEntity 6 | import com.gts.trackmypath.domain.model.Photo 7 | 8 | data class ResponseEntity( 9 | @Json(name = "photos") 10 | val photosResponseEntity: PhotosResponseEntity 11 | ) 12 | 13 | data class PhotosResponseEntity( 14 | @Json(name = "page") 15 | val page: Int, 16 | @Json(name = "pages") 17 | val pages: Int, 18 | @Json(name = "perpage") 19 | val perpage: String, 20 | @Json(name = "total") 21 | val total: String, 22 | @Json(name = "photo") 23 | val list: List 24 | ) 25 | 26 | data class PhotoResponseEntity( 27 | @Json(name = "id") 28 | val id: String, 29 | @Json(name = "secret") 30 | val secret: String, 31 | @Json(name = "server") 32 | val server: String, 33 | @Json(name = "farm") 34 | val farm: String 35 | ) 36 | 37 | // map to DB entity, PhotoEntity 38 | fun PhotoResponseEntity.toPhotoEntity() = PhotoEntity( 39 | photoId = 0, 40 | id = id, 41 | secret = secret, 42 | server = server, 43 | farm = farm 44 | ) 45 | 46 | fun PhotoResponseEntity.toDomainModel() = Photo( 47 | id = id, 48 | secret = secret, 49 | server = server, 50 | farm = farm 51 | ) 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/domain/LocationServiceInteractor.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain 2 | 3 | import com.gts.trackmypath.common.Result 4 | import com.gts.trackmypath.domain.model.Photo 5 | import com.gts.trackmypath.domain.usecase.ClearPhotosUseCase 6 | import com.gts.trackmypath.domain.usecase.SearchPhotoByLocationUseCase 7 | 8 | class LocationServiceInteractor( 9 | private val clearPhotosUseCase: ClearPhotosUseCase, 10 | private val searchPhotoByLocationUseCase: SearchPhotoByLocationUseCase 11 | ) { 12 | 13 | suspend fun clearPhotosFromList() { 14 | clearPhotosUseCase.invoke() 15 | } 16 | 17 | suspend fun getPhotoBasedOnLocation(latitude: Double, longitude: Double): Result { 18 | return searchPhotoByLocationUseCase.invoke(latitude, longitude) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/domain/PhotoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain 2 | 3 | import com.gts.trackmypath.common.Result 4 | import com.gts.trackmypath.domain.model.Photo 5 | 6 | // Repository modules handle data operations. 7 | // They provide a clean API so that the rest of the app can retrieve this data easily. 8 | interface PhotoRepository { 9 | 10 | suspend fun searchPhotoByLocation(lat: String, lon: String): Result 11 | 12 | suspend fun loadAllPhotos(): Result> 13 | 14 | suspend fun deletePhotos() 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/domain/model/Photo.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain.model 2 | 3 | data class Photo( 4 | val id: String, 5 | val secret: String, 6 | val server: String, 7 | val farm: String 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/domain/usecase/ClearPhotosUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain.usecase 2 | 3 | import com.gts.trackmypath.domain.PhotoRepository 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | 7 | class ClearPhotosUseCase(private val photoRepository: PhotoRepository) { 8 | 9 | suspend operator fun invoke() = withContext(Dispatchers.IO) { 10 | photoRepository.deletePhotos() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/domain/usecase/RetrievePhotosUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain.usecase 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | 6 | import com.gts.trackmypath.common.Result 7 | import com.gts.trackmypath.domain.PhotoRepository 8 | import com.gts.trackmypath.domain.model.Photo 9 | 10 | class RetrievePhotosUseCase(private val photoRepository: PhotoRepository) { 11 | 12 | suspend operator fun invoke(): Result> = withContext(Dispatchers.IO) { 13 | return@withContext photoRepository.loadAllPhotos() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/domain/usecase/SearchPhotoByLocationUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain.usecase 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | 6 | import com.gts.trackmypath.common.Result 7 | import com.gts.trackmypath.domain.PhotoRepository 8 | import com.gts.trackmypath.domain.model.Photo 9 | 10 | class SearchPhotoByLocationUseCase(private val photoRepository: PhotoRepository) { 11 | 12 | suspend operator fun invoke(lat: Double, lon: Double): Result = withContext(Dispatchers.IO) { 13 | return@withContext photoRepository.searchPhotoByLocation(lat.toString(), lon.toString()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | 6 | import com.gts.trackmypath.R 7 | 8 | class MainActivity : AppCompatActivity() { 9 | 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContentView(R.layout.activity_main) 13 | if (savedInstanceState == null) { 14 | supportFragmentManager.beginTransaction() 15 | .replace(R.id.container, PhotoStreamFragment.newInstance()) 16 | .commitNow() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/PhotoAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import android.view.ViewGroup 4 | import android.view.LayoutInflater 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | import com.facebook.drawee.view.SimpleDraweeView 8 | 9 | import com.gts.trackmypath.databinding.PhotoItemBinding 10 | import com.gts.trackmypath.presentation.model.PhotoViewItem 11 | 12 | class PhotoAdapter(private val photos: MutableList) : 13 | RecyclerView.Adapter() { 14 | 15 | private var _binding: PhotoItemBinding? = null 16 | private val binding get() = _binding 17 | 18 | fun addPhoto(photo: PhotoViewItem) { 19 | // Add the photo at the beginning of the list 20 | photos.add(0, photo) 21 | notifyItemInserted(0) 22 | } 23 | 24 | fun populate(photoItems: List) { 25 | photos.addAll(photoItems) 26 | notifyDataSetChanged() 27 | } 28 | 29 | fun resetPhotoList() { 30 | photos.clear() 31 | notifyDataSetChanged() 32 | } 33 | 34 | override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): PhotoViewHolder { 35 | _binding = 36 | PhotoItemBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false) 37 | return PhotoViewHolder(binding) 38 | } 39 | 40 | override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) { 41 | val photo = photos[position] 42 | holder.photoImageView.setImageURI( 43 | buildUri( 44 | photo.farm, 45 | photo.server, 46 | photo.id, 47 | photo.secret 48 | ) 49 | ) 50 | } 51 | 52 | // override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { 53 | // super.onDetachedFromRecyclerView(recyclerView) 54 | // _binding = null // we actually need this part 55 | // } 56 | 57 | override fun getItemCount(): Int = photos.size 58 | 59 | class PhotoViewHolder(binding: PhotoItemBinding?) : RecyclerView.ViewHolder(binding!!.root) { 60 | var photoImageView: SimpleDraweeView = binding?.imageViewId as SimpleDraweeView 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/PhotoStreamFragment.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import android.Manifest 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.IntentFilter 7 | import android.content.ComponentName 8 | import android.content.ServiceConnection 9 | import android.content.SharedPreferences 10 | import android.content.BroadcastReceiver 11 | import android.content.pm.PackageManager 12 | import android.os.Bundle 13 | import android.os.IBinder 14 | import android.net.Uri 15 | import android.view.View 16 | import android.view.ViewGroup 17 | import android.view.LayoutInflater 18 | import android.provider.Settings 19 | import androidx.lifecycle.Observer 20 | import androidx.fragment.app.Fragment 21 | import androidx.core.app.ActivityCompat 22 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 23 | import androidx.preference.PreferenceManager 24 | 25 | import java.util.ArrayList 26 | 27 | import com.google.android.material.snackbar.Snackbar 28 | 29 | import org.koin.androidx.viewmodel.ext.android.viewModel 30 | 31 | import com.gts.trackmypath.R 32 | import com.gts.trackmypath.BuildConfig 33 | import com.gts.trackmypath.databinding.FragmentPhotoStreamBinding 34 | import com.gts.trackmypath.presentation.model.PhotoViewItem 35 | import com.gts.trackmypath.presentation.service.LocationService 36 | 37 | import timber.log.Timber 38 | 39 | class PhotoStreamFragment : Fragment() { 40 | 41 | companion object { 42 | fun newInstance() = PhotoStreamFragment() 43 | 44 | // Used in checking for runtime permissions. 45 | private const val REQUEST_PERMISSIONS_REQUEST_CODE = 34 46 | } 47 | 48 | private val viewModel: PhotoStreamViewModel by viewModel() 49 | 50 | // The BroadcastReceiver used to listen from broadcasts from the service. 51 | private lateinit var locationReceiver: LocationReceiver 52 | 53 | // A reference to the service used to get location updates. 54 | private lateinit var locationService: LocationService 55 | 56 | // used to store button state 57 | private lateinit var sharedPref: SharedPreferences 58 | 59 | // recycler view and adapter for retrieved photos 60 | private lateinit var photoAdapter: PhotoAdapter 61 | 62 | private lateinit var binding: FragmentPhotoStreamBinding 63 | 64 | // Monitors the state of the connection to the service. 65 | private val serviceConnection = object : ServiceConnection { 66 | 67 | override fun onServiceConnected(name: ComponentName, service: IBinder) { 68 | Timber.d("ServiceConnection: onServiceConnected") 69 | val binder = service as LocationService.LocalBinder 70 | locationService = binder.service 71 | } 72 | 73 | override fun onServiceDisconnected(name: ComponentName) { 74 | Timber.d("ServiceConnection: onServiceDisconnected") 75 | } 76 | } 77 | 78 | override fun onCreate(savedInstanceState: Bundle?) { 79 | super.onCreate(savedInstanceState) 80 | 81 | sharedPref = activity?.getPreferences(Context.MODE_PRIVATE) ?: return 82 | locationReceiver = LocationReceiver() 83 | } 84 | 85 | override fun onCreateView( 86 | inflater: LayoutInflater, 87 | container: ViewGroup?, 88 | savedInstanceState: Bundle? 89 | ): View? { 90 | binding = FragmentPhotoStreamBinding.inflate(layoutInflater, container, false) 91 | return binding.root 92 | } 93 | 94 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 95 | super.onViewCreated(view, savedInstanceState) 96 | 97 | val photoList = ArrayList() 98 | photoAdapter = PhotoAdapter(photoList) 99 | binding.imageRecyclerView.adapter = photoAdapter 100 | binding.imageRecyclerView.isNestedScrollingEnabled = false 101 | 102 | viewModel.photosByLocation.observe(viewLifecycleOwner, Observer { photos -> 103 | photoAdapter.populate(photos) 104 | binding.imageRecyclerView.smoothScrollToPosition(0) 105 | }) 106 | } 107 | 108 | override fun onStart() { 109 | super.onStart() 110 | 111 | photoAdapter.resetPhotoList() 112 | viewModel.retrievePhotos() 113 | 114 | binding.buttonStart.text = PreferenceManager.getDefaultSharedPreferences(context) 115 | .getString(getString(R.string.service_state), "Start") 116 | binding.buttonStart.setOnClickListener { 117 | if (binding.buttonStart.text == getString(R.string.button_text_stop)) { 118 | locationService.removeLocationUpdates() 119 | binding.buttonStart.text = getString(R.string.button_text_start) 120 | } else { 121 | if (!checkPermissions()) { 122 | requestPermissions() 123 | } else { 124 | locationService.requestLocationUpdates() 125 | } 126 | photoAdapter.resetPhotoList() 127 | binding.buttonStart.text = getString(R.string.button_text_stop) 128 | } 129 | } 130 | 131 | // Bind to the service. If the service is in foreground mode, this signals to the service 132 | // that since this activity is in the foreground, the service can exit foreground mode. 133 | requireActivity().bindService( 134 | Intent(context, LocationService::class.java), 135 | serviceConnection, 136 | Context.BIND_AUTO_CREATE 137 | ) 138 | } 139 | 140 | override fun onResume() { 141 | super.onResume() 142 | LocalBroadcastManager.getInstance(requireContext()).registerReceiver( 143 | locationReceiver, 144 | IntentFilter(LocationService.ACTION_BROADCAST) 145 | ) 146 | } 147 | 148 | override fun onPause() { 149 | LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(locationReceiver) 150 | super.onPause() 151 | } 152 | 153 | override fun onStop() { 154 | // Unbind from the service. This signals to the service that this activity is no longer in the foreground, 155 | // and the service can respond by promoting itself to a foreground service. 156 | requireActivity().unbindService(serviceConnection) 157 | super.onStop() 158 | } 159 | 160 | /** 161 | * Returns the current state of the permissions needed. 162 | */ 163 | private fun checkPermissions(): Boolean { 164 | return PackageManager.PERMISSION_GRANTED == ActivityCompat.checkSelfPermission( 165 | requireContext(), 166 | Manifest.permission.ACCESS_FINE_LOCATION 167 | ) 168 | } 169 | 170 | private fun requestPermissions() { 171 | val shouldProvideRationale = ActivityCompat.shouldShowRequestPermissionRationale( 172 | requireActivity(), 173 | Manifest.permission.ACCESS_FINE_LOCATION 174 | ) 175 | 176 | // Provide an additional rationale to the user. This would happen if the user denied the 177 | // request previously, but didn't check the "Don't ask again" checkbox. 178 | if (shouldProvideRationale) { 179 | Snackbar.make( 180 | binding.fragmentMain, 181 | getString(R.string.location_permission_text), 182 | Snackbar.LENGTH_INDEFINITE 183 | ).setAction(getString(R.string.location_permission_action_ok_text)) { 184 | requestPermissions( 185 | arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 186 | REQUEST_PERMISSIONS_REQUEST_CODE 187 | ) 188 | }.show() 189 | } else { 190 | // Request permission. It's possible this can be auto answered if device policy sets the permission 191 | // in a given state or the user denied the permission previously and checked "Never ask again". 192 | requestPermissions( 193 | arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 194 | REQUEST_PERMISSIONS_REQUEST_CODE 195 | ) 196 | } 197 | } 198 | 199 | /** 200 | * Callback received when a permissions request has been completed. 201 | */ 202 | override fun onRequestPermissionsResult( 203 | requestCode: Int, 204 | permissions: Array, 205 | grantResults: IntArray 206 | ) { 207 | if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE) { 208 | when { 209 | grantResults.isEmpty() -> 210 | // If user interaction was interrupted, the permission request is cancelled 211 | // and you receive empty arrays. 212 | Timber.i("=======> User interaction was cancelled.") 213 | grantResults[0] == PackageManager.PERMISSION_GRANTED -> { 214 | locationService.requestLocationUpdates() 215 | } 216 | else -> // Permission denied. 217 | Snackbar.make( 218 | binding.fragmentMain, 219 | getString(R.string.location_permission_denied_text), 220 | Snackbar.LENGTH_INDEFINITE 221 | ) 222 | .setAction(getString(R.string.location_permission_action_settings_text)) { 223 | // Build intent that displays the App settings screen. 224 | val intent = Intent() 225 | intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS 226 | val uri = Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) 227 | intent.data = uri 228 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK 229 | startActivity(intent) 230 | }.show() 231 | } 232 | } 233 | } 234 | 235 | /** 236 | * Receiver for broadcasts sent by [LocationService]. 237 | */ 238 | private inner class LocationReceiver : BroadcastReceiver() { 239 | override fun onReceive(context: Context, intent: Intent) { 240 | val photo = intent.getParcelableExtra(LocationService.EXTRA_PHOTO) 241 | if (photo != null) { 242 | photoAdapter.addPhoto(photo) 243 | binding.imageRecyclerView.smoothScrollToPosition(0) 244 | } 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/PhotoStreamViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.viewModelScope 7 | 8 | import kotlinx.coroutines.launch 9 | 10 | import com.gts.trackmypath.common.Result 11 | import com.gts.trackmypath.domain.usecase.RetrievePhotosUseCase 12 | import com.gts.trackmypath.presentation.model.PhotoViewItem 13 | import com.gts.trackmypath.presentation.model.toPresentationModel 14 | 15 | import timber.log.Timber 16 | 17 | class PhotoStreamViewModel( 18 | private val retrievePhotosUseCase: RetrievePhotosUseCase 19 | ) : ViewModel() { 20 | 21 | private val _photosByLocation = MutableLiveData>() 22 | val photosByLocation: LiveData> 23 | get() = _photosByLocation 24 | 25 | fun retrievePhotos() { 26 | viewModelScope.launch { 27 | when (val result = retrievePhotosUseCase.invoke()) { 28 | is Result.Success -> { 29 | _photosByLocation.postValue(result.data.map { it.toPresentationModel() }) 30 | } 31 | is Result.Error -> Timber.d("no photos") 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | fun buildUri(farm: String, server: String, id: String, secret: String): String { 4 | return ("https://farm$farm.staticflickr.com/$server/${id}_$secret.jpg") 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/model/PhotoViewItem.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation.model 2 | 3 | import android.os.Parcelable 4 | 5 | import kotlinx.parcelize.Parcelize 6 | 7 | import com.gts.trackmypath.domain.model.Photo 8 | 9 | @Parcelize 10 | data class PhotoViewItem( 11 | val id: String, 12 | val secret: String, 13 | val server: String, 14 | val farm: String 15 | ) : Parcelable 16 | 17 | fun Photo.toPresentationModel() = PhotoViewItem( 18 | id = id, 19 | secret = secret, 20 | server = server, 21 | farm = farm 22 | ) 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/gts/trackmypath/presentation/service/LocationService.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.gts.trackmypath.presentation.service 18 | 19 | import android.os.Build 20 | import android.os.Binder 21 | import android.os.Looper 22 | import android.os.IBinder 23 | import android.os.Handler 24 | import android.os.HandlerThread 25 | import android.app.ActivityManager 26 | import android.app.PendingIntent 27 | import android.app.Notification 28 | import android.app.NotificationChannel 29 | import android.app.NotificationManager 30 | import android.content.Intent 31 | import android.content.Context 32 | import android.location.Location 33 | import androidx.core.app.NotificationCompat 34 | import androidx.lifecycle.LifecycleService 35 | import androidx.preference.PreferenceManager 36 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 37 | 38 | import com.google.android.gms.location.LocationResult 39 | import com.google.android.gms.location.LocationRequest 40 | import com.google.android.gms.location.LocationServices 41 | import com.google.android.gms.location.LocationCallback 42 | import com.google.android.gms.location.FusedLocationProviderClient 43 | 44 | import kotlinx.coroutines.SupervisorJob 45 | import kotlinx.coroutines.CoroutineScope 46 | import kotlinx.coroutines.Dispatchers 47 | import kotlinx.coroutines.launch 48 | 49 | import org.koin.android.ext.android.inject 50 | 51 | import com.gts.trackmypath.R 52 | import com.gts.trackmypath.common.Result 53 | import com.gts.trackmypath.domain.LocationServiceInteractor 54 | import com.gts.trackmypath.presentation.MainActivity 55 | import com.gts.trackmypath.presentation.model.toPresentationModel 56 | 57 | import java.util.concurrent.TimeUnit 58 | import timber.log.Timber 59 | 60 | /** 61 | * A bound and started service that is promoted to a foreground service when location updates have 62 | * been requested and all clients unbind. 63 | * 64 | * This sample show how to use a long-running service for location updates. When an activity is 65 | * bound to this service, frequent location updates are permitted. When the activity is removed 66 | * from the foreground, the service promotes itself to a foreground service, and location updates 67 | * continue. When the activity comes back to the foreground, the foreground service stops, and the 68 | * notification associated with that service is removed. 69 | */ 70 | class LocationService : LifecycleService() { 71 | 72 | companion object { 73 | const val EXTRA_PHOTO = "location" 74 | const val ACTION_BROADCAST = "broadcast" 75 | 76 | private val TAG = LocationService::class.java.simpleName 77 | 78 | // The name of the channel for notifications. 79 | private const val NOTIFICATION_CHANNEL_ID = "channel_01" 80 | 81 | // The identifier for the notification displayed for the foreground service. 82 | private const val NOTIFICATION_ID = 1101 83 | private const val EXTRA_STARTED_FROM_NOTIFICATION = "started_from_notification" 84 | 85 | private const val SMALLEST_DISPLACEMENT_100_METERS = 100F 86 | private const val INTERVAL_TIME = 60 87 | private const val FASTEST_INTERVAL_TIME = 30 88 | } 89 | 90 | private val binder = LocalBinder() 91 | 92 | // A data object that contains quality of service parameters for requests 93 | private lateinit var locationRequest: LocationRequest 94 | 95 | // Provides access to the Fused Location Provider API. 96 | private lateinit var fusedLocationClient: FusedLocationProviderClient 97 | 98 | // Callback for changes in location. 99 | private lateinit var locationCallback: LocationCallback 100 | private lateinit var serviceHandler: Handler 101 | 102 | // The current location. 103 | private lateinit var location: Location 104 | private lateinit var notificationManager: NotificationManager 105 | 106 | private val locationServiceInteractor: LocationServiceInteractor by inject() 107 | private val job = SupervisorJob() 108 | private val scope = CoroutineScope(Dispatchers.IO + job) 109 | 110 | override fun onCreate() { 111 | super.onCreate() 112 | fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) 113 | 114 | locationCallback = object : LocationCallback() { 115 | override fun onLocationResult(locationResult: LocationResult?) { 116 | super.onLocationResult(locationResult) 117 | onNewLocation(locationResult!!.lastLocation) 118 | } 119 | } 120 | 121 | createLocationRequest() 122 | getLastLocation() 123 | 124 | val handlerThread = HandlerThread(TAG) 125 | handlerThread.start() 126 | serviceHandler = Handler(handlerThread.looper) 127 | notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 128 | 129 | // Android O requires a Notification Channel. 130 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 131 | val name = getString(R.string.app_name) 132 | // Create the channel for the notification 133 | val mChannel = NotificationChannel( 134 | NOTIFICATION_CHANNEL_ID, 135 | name, 136 | NotificationManager.IMPORTANCE_DEFAULT 137 | ) 138 | 139 | // Set the Notification Channel for the Notification Manager. 140 | notificationManager.createNotificationChannel(mChannel) 141 | } 142 | } 143 | 144 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 145 | Timber.tag(TAG).i("LocationService started") 146 | val startedFromNotification = intent?.getBooleanExtra( 147 | EXTRA_STARTED_FROM_NOTIFICATION, 148 | false 149 | ) 150 | 151 | // We got here because the user decided to remove location updates from the notification. 152 | if (startedFromNotification!!) { 153 | removeLocationUpdates() 154 | stopSelf() 155 | } 156 | // Tells the system to not try to recreate the service after it has been killed. 157 | return super.onStartCommand(intent, flags, START_NOT_STICKY) 158 | } 159 | 160 | override fun onBind(intent: Intent): IBinder { 161 | super.onBind(intent) 162 | // Called when a client (MainActivity in case of this app) comes to the foreground 163 | // and binds with this service. 164 | // The service should cease to be a foreground service when that happens. 165 | Timber.tag(TAG).i("in onBind()") 166 | stopForeground(true) 167 | return binder 168 | } 169 | 170 | override fun onRebind(intent: Intent) { 171 | // Called when a client (MainActivity in case of this sample) returns to the foreground and 172 | // binds once again with this service. 173 | // The service should cease to be a foreground service when that happens. 174 | Timber.tag(TAG).i("in onRebind()") 175 | stopForeground(true) 176 | super.onRebind(intent) 177 | } 178 | 179 | override fun onUnbind(intent: Intent): Boolean { 180 | Timber.tag(TAG).i("Last client unbound from service") 181 | Timber.tag(TAG).i("Starting foreground service") 182 | startForeground(NOTIFICATION_ID, getNotification()) 183 | return true // Ensures onRebind() is called when a client re-binds. 184 | } 185 | 186 | override fun onDestroy() { 187 | serviceHandler.removeCallbacksAndMessages(null) 188 | job.cancel() 189 | super.onDestroy() 190 | } 191 | 192 | /** 193 | * Makes a request for location updates. Note that in this sample we merely log the [SecurityException]. 194 | */ 195 | fun requestLocationUpdates() { 196 | Timber.tag(TAG).i("Requesting location updates") 197 | startService(Intent(applicationContext, LocationService::class.java)) 198 | try { 199 | saveServiceState(getString(R.string.service_state_started)) 200 | fusedLocationClient.requestLocationUpdates( 201 | locationRequest, locationCallback, Looper.myLooper() 202 | ) 203 | scope.launch { 204 | locationServiceInteractor.clearPhotosFromList() 205 | } 206 | } catch (unlikely: SecurityException) { 207 | saveServiceState(getString(R.string.service_state_stopped)) 208 | Timber.tag(TAG).e("Lost location permission. Could not request updates. $unlikely") 209 | } 210 | } 211 | 212 | /** 213 | * Removes location updates. Note that in this sample we merely log the [SecurityException]. 214 | */ 215 | fun removeLocationUpdates() { 216 | Timber.tag(TAG).i("Removing location updates") 217 | try { 218 | saveServiceState(getString(R.string.service_state_stopped)) 219 | fusedLocationClient.removeLocationUpdates(locationCallback) 220 | stopSelf() 221 | } catch (unlikely: SecurityException) { 222 | saveServiceState(getString(R.string.service_state_started)) 223 | Timber.tag(TAG).e("Lost location permission. Could not remove updates. $unlikely") 224 | } 225 | } 226 | 227 | private fun getLastLocation() { 228 | try { 229 | fusedLocationClient.lastLocation.addOnCompleteListener { task -> 230 | if (task.isSuccessful && task.result != null) { 231 | location = task.result!! 232 | } else { 233 | Timber.tag(TAG).w("Failed to get location.") 234 | } 235 | } 236 | } catch (unlikely: SecurityException) { 237 | Timber.tag(TAG).e("Lost location permission.$unlikely") 238 | } 239 | } 240 | 241 | private fun onNewLocation(location: Location) { 242 | Timber.tag(TAG).i("New location: $location") 243 | this.location = location 244 | 245 | scope.launch { 246 | when (val result = locationServiceInteractor.getPhotoBasedOnLocation( 247 | location.latitude, location.longitude 248 | ) 249 | ) { 250 | is Result.Success -> { 251 | // Notify anyone listening for broadcasts about the new photo. 252 | val intent = Intent(ACTION_BROADCAST) 253 | intent.putExtra(EXTRA_PHOTO, result.data.toPresentationModel()) 254 | LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) 255 | } 256 | is Result.Error -> Timber.tag(TAG).e(" LocationService error!") 257 | } 258 | } 259 | 260 | // Update notification content if running as a foreground service. 261 | if (serviceIsRunningInForeground(this)) { 262 | notificationManager.notify(NOTIFICATION_ID, getNotification()) 263 | } 264 | } 265 | 266 | /** 267 | * Sets the location request parameters. 268 | */ 269 | private fun createLocationRequest() { 270 | locationRequest = LocationRequest().apply { 271 | priority = LocationRequest.PRIORITY_HIGH_ACCURACY 272 | smallestDisplacement = SMALLEST_DISPLACEMENT_100_METERS // 100 meters 273 | interval = TimeUnit.SECONDS.toMillis(INTERVAL_TIME.toLong()) 274 | fastestInterval = TimeUnit.SECONDS.toMillis(FASTEST_INTERVAL_TIME.toLong()) 275 | } 276 | } 277 | 278 | /** 279 | * Class used for the client Binder. 280 | * Since this service runs in the same process as its clients, we don't need to deal with IPC. 281 | */ 282 | inner class LocalBinder : Binder() { 283 | internal val service: LocationService 284 | get() = this@LocationService 285 | } 286 | 287 | /** 288 | * Returns true if this is a foreground service. 289 | * 290 | * @param context The [Context]. 291 | */ 292 | @SuppressWarnings("deprecation") 293 | private fun serviceIsRunningInForeground(context: Context): Boolean { 294 | val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager 295 | for (service in manager.getRunningServices(Integer.MAX_VALUE)) { 296 | if (javaClass.name == service.service.className) { 297 | if (service.foreground) { 298 | return true 299 | } 300 | } 301 | } 302 | return false 303 | } 304 | 305 | /** 306 | * Returns the [NotificationCompat] used as part of the foreground service. 307 | */ 308 | private fun getNotification(): Notification { 309 | val intent = Intent(this, LocationService::class.java) 310 | // Extra to help us figure out if we arrived in onStartCommand via the notification or not. 311 | intent.putExtra(EXTRA_STARTED_FROM_NOTIFICATION, true) 312 | // The PendingIntent that leads to a call to onStartCommand() in this service. 313 | val servicePendingIntent = PendingIntent.getService( 314 | this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT 315 | ) 316 | // The PendingIntent to launch activity. 317 | val activityPendingIntent = PendingIntent.getActivity( 318 | this, 0, Intent(this, MainActivity::class.java), 0 319 | ) 320 | 321 | val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) 322 | .addAction(0, getString(R.string.notification_action_launch), activityPendingIntent) 323 | .addAction(0, getString(R.string.notification_action_stop), servicePendingIntent) 324 | .setContentTitle(getString(R.string.notification_content_title)) 325 | .setContentText(getString(R.string.notification_content_text)) 326 | .setOngoing(true) 327 | .setPriority(1) // Notification.PRIORITY_HIGH 328 | .setSmallIcon(R.mipmap.ic_launcher) 329 | .setWhen(System.currentTimeMillis()) 330 | 331 | // Set the Channel ID for Android O. 332 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 333 | builder.setChannelId(NOTIFICATION_CHANNEL_ID) // Channel ID 334 | } 335 | 336 | return builder.build() 337 | } 338 | 339 | /** 340 | * Save the state of the Service. 341 | * The UI retrieves the state to set the Button text. 342 | */ 343 | private fun saveServiceState(state: String) { 344 | PreferenceManager.getDefaultSharedPreferences(applicationContext) 345 | .edit() 346 | .putString(getString(R.string.service_state), state) 347 | .apply() 348 | } 349 | 350 | } 351 | -------------------------------------------------------------------------------- /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/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_photo_stream.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 19 | 20 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/layout/photo_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Track my path 3 | SERVICE_STATE 4 | Track my path 5 | application is running 6 | Launch app 7 | Stop 8 | Stop 9 | Start 10 | Start 11 | Stop 12 | Location permission is needed for core functionality 13 | OK 14 | permission denied 15 | Settings 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/test/java/com/gts/trackmypath/data/repository/PhotoRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.data.repository 2 | 3 | import org.junit.Test 4 | import org.junit.Before 5 | import org.junit.Assert 6 | 7 | import com.nhaarman.mockitokotlin2.mock 8 | import com.nhaarman.mockitokotlin2.verify 9 | import com.nhaarman.mockitokotlin2.whenever 10 | 11 | import kotlinx.coroutines.runBlocking 12 | 13 | import com.gts.trackmypath.common.Result 14 | import com.gts.trackmypath.data.PhotoRepositoryImpl 15 | import com.gts.trackmypath.data.database.PhotoDao 16 | import com.gts.trackmypath.data.database.PhotoEntity 17 | import com.gts.trackmypath.data.network.toDomainModel 18 | import com.gts.trackmypath.data.network.toPhotoEntity 19 | import com.gts.trackmypath.data.network.FlickrClient 20 | import com.gts.trackmypath.data.network.PhotoResponseEntity 21 | 22 | class PhotoRepositoryImplTest { 23 | 24 | private lateinit var repository: PhotoRepositoryImpl 25 | 26 | private val mockFlickrClient: FlickrClient = mock() 27 | private val mockPhotoDatabase: PhotoDao = mock() 28 | 29 | private val lat = "lat" 30 | private val lon = "lon" 31 | private val radius = "0.1" 32 | private val id1 = "id1" 33 | private val id2 = "id2" 34 | private val secret = "secret" 35 | private val server = "server" 36 | private val farm = "farm" 37 | private val photoResponseEntity1 = PhotoResponseEntity(id1, secret, server, farm) 38 | private val photoEntity1 = photoResponseEntity1.toPhotoEntity() 39 | private val photoResponseEntity2 = PhotoResponseEntity(id2, secret, server, farm) 40 | private val photoEntity2 = photoResponseEntity2.toPhotoEntity() 41 | 42 | @Before 43 | fun setUp() { 44 | repository = PhotoRepositoryImpl(mockFlickrClient, mockPhotoDatabase) 45 | } 46 | 47 | @Test 48 | fun `given empty database, when searchPhotoByLocation, then return the first photo`() { 49 | runBlocking { 50 | val photosFromDb = arrayOf() 51 | whenever(mockPhotoDatabase.selectAllPhotos()).thenReturn(photosFromDb) 52 | 53 | val photosFromFlickr = listOf(photoResponseEntity1) 54 | val result = Result.Success(photosFromFlickr) 55 | whenever(mockFlickrClient.searchPhoto(lat, lon, radius)).thenReturn(result) 56 | whenever(mockPhotoDatabase.insert(photoEntity1)).thenReturn(Unit) 57 | 58 | val test = repository.searchPhotoByLocation(lat, lon) 59 | 60 | verify(mockFlickrClient).searchPhoto(lat, lon, radius) 61 | Assert.assertEquals(test, Result.Success(result.data[0].toDomainModel())) 62 | } 63 | } 64 | 65 | @Test 66 | fun `given filled database, when searchPhotoByLocation returns existing photo, then return next photo`() { 67 | runBlocking { 68 | val photosFromDb = arrayOf(photoEntity1) 69 | whenever(mockPhotoDatabase.selectAllPhotos()).thenReturn(photosFromDb) 70 | 71 | val photosFromFlickr = listOf(photoResponseEntity1, photoResponseEntity2) 72 | val result = Result.Success(photosFromFlickr) 73 | whenever(mockFlickrClient.searchPhoto(lat, lon, radius)).thenReturn(result) 74 | whenever(mockPhotoDatabase.insert(photoEntity2)).thenReturn(Unit) 75 | 76 | val test = repository.searchPhotoByLocation(lat, lon) 77 | 78 | verify(mockFlickrClient).searchPhoto(lat, lon, radius) 79 | Assert.assertEquals(test, Result.Success(result.data[1].toDomainModel())) 80 | } 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /app/src/test/java/com/gts/trackmypath/domain/RetrievePhotosUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain 2 | 3 | import org.junit.Test 4 | import org.junit.Before 5 | import org.junit.Assert.assertEquals 6 | 7 | import java.io.IOException 8 | 9 | import kotlinx.coroutines.runBlocking 10 | 11 | import com.nhaarman.mockitokotlin2.mock 12 | import com.nhaarman.mockitokotlin2.verify 13 | import com.nhaarman.mockitokotlin2.whenever 14 | 15 | import com.gts.trackmypath.common.Result 16 | import com.gts.trackmypath.domain.model.Photo 17 | import com.gts.trackmypath.domain.usecase.RetrievePhotosUseCase 18 | 19 | class RetrievePhotosUseCaseTest { 20 | 21 | private lateinit var retrievePhotosUseCase: RetrievePhotosUseCase 22 | private val mockPhotoRepository: PhotoRepository = mock() 23 | private val photo = Photo("id", "secret", "server", "farm") 24 | 25 | @Before 26 | fun setUp() { 27 | retrievePhotosUseCase = 28 | RetrievePhotosUseCase( 29 | mockPhotoRepository 30 | ) 31 | } 32 | 33 | @Test 34 | fun `when repository succeeds to retrieve photos then retrieve photos usecase returns success with photos`() { 35 | runBlocking { 36 | // given 37 | val expected = Result.Success(listOf(photo)) 38 | whenever(mockPhotoRepository.loadAllPhotos()).thenReturn(expected) 39 | // when 40 | val result = retrievePhotosUseCase.invoke() 41 | // then 42 | verify(mockPhotoRepository).loadAllPhotos() 43 | assertEquals(expected, result) 44 | } 45 | } 46 | 47 | @Test 48 | fun `when repository fails to retrieve photos then retrieve photos usecase returns error`() { 49 | runBlocking { 50 | // given 51 | val expected = Result.Error(IOException("Failed to retrieve photos from database")) 52 | whenever(mockPhotoRepository.loadAllPhotos()).thenReturn(expected) 53 | // when 54 | val result = retrievePhotosUseCase.invoke() 55 | // then 56 | verify(mockPhotoRepository).loadAllPhotos() 57 | assertEquals(expected, result) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/test/java/com/gts/trackmypath/domain/SearchPhotoByLocationUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.domain 2 | 3 | import org.junit.Before 4 | import org.junit.Test 5 | import org.junit.Assert.assertEquals 6 | 7 | import java.io.IOException 8 | 9 | import kotlinx.coroutines.runBlocking 10 | 11 | import com.nhaarman.mockitokotlin2.mock 12 | import com.nhaarman.mockitokotlin2.verify 13 | import com.nhaarman.mockitokotlin2.whenever 14 | 15 | import com.gts.trackmypath.common.Result 16 | import com.gts.trackmypath.domain.model.Photo 17 | import com.gts.trackmypath.domain.usecase.SearchPhotoByLocationUseCase 18 | 19 | class SearchPhotoByLocationUseCaseTest { 20 | 21 | private lateinit var searchPhotoByLocationUseCase: SearchPhotoByLocationUseCase 22 | private val mockPhotoRepository: PhotoRepository = mock() 23 | private val lat = 1.0 24 | private val lon = 1.0 25 | private val photo = Photo("id", "secret", "server", "farm") 26 | 27 | @Before 28 | fun setUp() { 29 | searchPhotoByLocationUseCase = 30 | SearchPhotoByLocationUseCase( 31 | mockPhotoRepository 32 | ) 33 | } 34 | 35 | @Test 36 | fun `searchPhotoByLocation get success`() { 37 | runBlocking { 38 | // given 39 | val expected = Result.Success(photo) 40 | whenever(mockPhotoRepository.searchPhotoByLocation(lat.toString(), lon.toString())).thenReturn(expected) 41 | // when 42 | val result = searchPhotoByLocationUseCase.invoke(lat, lon) 43 | // then 44 | verify(mockPhotoRepository).searchPhotoByLocation(lat.toString(), lon.toString()) 45 | assertEquals(expected, result) 46 | } 47 | } 48 | 49 | @Test 50 | fun `searchPhotoByLocation get error`() { 51 | runBlocking { 52 | // given 53 | val expected = Result.Error(IOException("searchPhotoByLocation response error")) 54 | whenever(mockPhotoRepository.searchPhotoByLocation(lat.toString(), lon.toString())).thenReturn(expected) 55 | // when 56 | val result = searchPhotoByLocationUseCase.invoke(lat, lon) 57 | // then 58 | verify(mockPhotoRepository).searchPhotoByLocation(lat.toString(), lon.toString()) 59 | assertEquals(expected, result) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/test/java/com/gts/trackmypath/presentation/LiveDataTestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import androidx.lifecycle.Observer 4 | import androidx.lifecycle.LiveData 5 | 6 | import java.util.concurrent.TimeUnit 7 | import java.util.concurrent.CountDownLatch 8 | 9 | /** 10 | * Safely handles observables from LiveData for testing. 11 | */ 12 | object LiveDataTestUtil { 13 | 14 | /** 15 | * Gets the value of a LiveData safely. 16 | */ 17 | @Throws(InterruptedException::class) 18 | fun getValue(liveData: LiveData): T? { 19 | var data: T? = null 20 | val latch = CountDownLatch(1) 21 | val observer = object : Observer { 22 | override fun onChanged(o: T?) { 23 | data = o 24 | latch.countDown() 25 | liveData.removeObserver(this) 26 | } 27 | } 28 | liveData.observeForever(observer) 29 | latch.await(2, TimeUnit.SECONDS) 30 | 31 | return data 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/test/java/com/gts/trackmypath/presentation/PhotoStreamViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import org.junit.Test 4 | import org.junit.Rule 5 | import org.junit.Before 6 | import org.junit.Assert 7 | 8 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 9 | 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.runBlocking 12 | import kotlinx.coroutines.test.setMain 13 | import kotlinx.coroutines.ExperimentalCoroutinesApi 14 | 15 | import com.nhaarman.mockitokotlin2.mock 16 | import com.nhaarman.mockitokotlin2.whenever 17 | 18 | import com.gts.trackmypath.common.Result 19 | import com.gts.trackmypath.domain.model.Photo 20 | import com.gts.trackmypath.domain.usecase.RetrievePhotosUseCase 21 | import com.gts.trackmypath.presentation.model.PhotoViewItem 22 | import com.gts.trackmypath.presentation.model.toPresentationModel 23 | 24 | class PhotoStreamViewModelTest { 25 | 26 | @get:Rule 27 | var instantTaskExecutorRule = InstantTaskExecutorRule() 28 | 29 | private lateinit var viewModel: PhotoStreamViewModel 30 | private val mockRetrievePhotosUseCase: RetrievePhotosUseCase = mock() 31 | 32 | @ExperimentalCoroutinesApi 33 | @Before 34 | fun setUp() { 35 | Dispatchers.setMain(Dispatchers.Unconfined) 36 | viewModel = PhotoStreamViewModel(mockRetrievePhotosUseCase) 37 | } 38 | 39 | @Test 40 | fun `given a photo, when view model retrieves photo, then returns a list with the photo item`() { 41 | val photo = Photo("id", "secret", "server", "farm") 42 | val expected = Result.Success(listOf(photo)) 43 | 44 | runBlocking { 45 | whenever(mockRetrievePhotosUseCase.invoke()).thenReturn(expected) 46 | 47 | viewModel.retrievePhotos() 48 | 49 | val photoList: List? = 50 | LiveDataTestUtil.getValue(viewModel.photosByLocation) 51 | Assert.assertNotNull(photoList) 52 | Assert.assertEquals(expected.data.map { it.toPresentationModel() }, photoList) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/test/java/com/gts/trackmypath/presentation/UtilsTest.kt: -------------------------------------------------------------------------------- 1 | package com.gts.trackmypath.presentation 2 | 3 | import org.junit.Test 4 | import org.junit.Assert.assertEquals 5 | 6 | class UtilsTest { 7 | 8 | @Test 9 | fun `buildUri returns right uri`() { 10 | val expected = "https://farm01.staticflickr.com/10/101_xxx.jpg" 11 | // when 12 | val farm = "01" 13 | val server = "10" 14 | val id = "101" 15 | val secret = "xxx" 16 | val result = buildUri(farm, server, id, secret) 17 | // then 18 | assertEquals(expected, result) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext { 5 | kotlin_version = '1.4.21' 6 | appcompat_version = '1.2.0' 7 | playServicesLocation_version = '17.1.0' 8 | material_version = '1.2.1' 9 | ktx_version = '1.5.0-alpha05' 10 | constraintLayout_version = '2.0.4' 11 | recyclerview_version = '28.0.0' 12 | lifecycle_version = '2.2.0' 13 | localBroadcastManager_version = '1.0.0' 14 | preference_ktx_version = '1.1.1' 15 | room_version = '2.2.6' 16 | koin_version = '2.1.6' 17 | coroutines_version = '1.3.9' 18 | retrofit_version = '2.9.0' 19 | retrofitCoroutinesAdapter_version = '0.9.2' 20 | okHttp_version = '4.9.0' 21 | moshi_version = '1.11.0' 22 | moshiConverter_version = '2.9.0' 23 | fresco_version = '2.0.0' 24 | timber_version = '4.7.1' 25 | } 26 | 27 | repositories { 28 | google() 29 | jcenter() 30 | gradlePluginPortal() 31 | } 32 | 33 | dependencies { 34 | classpath 'com.android.tools.build:gradle:4.1.1' 35 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 36 | classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.15.0" 37 | classpath "io.gitlab.arturbosch.detekt:detekt-cli:1.15.0" 38 | // NOTE: Do not place your application dependencies here; they belong 39 | // in the individual module build.gradle files 40 | } 41 | 42 | } 43 | 44 | allprojects { 45 | repositories { 46 | google() 47 | jcenter() 48 | } 49 | } 50 | 51 | task clean(type: Delete) { 52 | delete rootProject.buildDir 53 | } 54 | -------------------------------------------------------------------------------- /config/detekt/detekt.yml: -------------------------------------------------------------------------------- 1 | build: 2 | maxIssues: 0 3 | excludeCorrectable: false 4 | weights: 5 | # complexity: 2 6 | # LongParameterList: 1 7 | # style: 1 8 | # comments: 1 9 | 10 | config: 11 | validation: true 12 | warningsAsErrors: false 13 | # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' 14 | excludes: '' 15 | 16 | processors: 17 | active: true 18 | exclude: 19 | - 'DetektProgressListener' 20 | # - 'FunctionCountProcessor' 21 | # - 'PropertyCountProcessor' 22 | # - 'ClassCountProcessor' 23 | # - 'PackageCountProcessor' 24 | # - 'KtFileCountProcessor' 25 | 26 | console-reports: 27 | active: true 28 | exclude: 29 | - 'ProjectStatisticsReport' 30 | - 'ComplexityReport' 31 | - 'NotificationReport' 32 | # - 'FindingsReport' 33 | - 'FileBasedFindingsReport' 34 | 35 | output-reports: 36 | active: true 37 | exclude: 38 | # - 'TxtOutputReport' 39 | # - 'XmlOutputReport' 40 | # - 'HtmlOutputReport' 41 | 42 | comments: 43 | active: true 44 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 45 | AbsentOrWrongFileLicense: 46 | active: false 47 | licenseTemplateFile: 'license.template' 48 | CommentOverPrivateFunction: 49 | active: false 50 | CommentOverPrivateProperty: 51 | active: false 52 | EndOfSentenceFormat: 53 | active: false 54 | endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' 55 | UndocumentedPublicClass: 56 | active: false 57 | searchInNestedClass: true 58 | searchInInnerClass: true 59 | searchInInnerObject: true 60 | searchInInnerInterface: true 61 | UndocumentedPublicFunction: 62 | active: false 63 | UndocumentedPublicProperty: 64 | active: false 65 | 66 | complexity: 67 | active: true 68 | ComplexCondition: 69 | active: true 70 | threshold: 4 71 | ComplexInterface: 72 | active: false 73 | threshold: 10 74 | includeStaticDeclarations: false 75 | includePrivateDeclarations: false 76 | ComplexMethod: 77 | active: true 78 | threshold: 15 79 | ignoreSingleWhenExpression: false 80 | ignoreSimpleWhenEntries: false 81 | ignoreNestingFunctions: false 82 | nestingFunctions: [run, let, apply, with, also, use, forEach, isNotNull, ifNull] 83 | LabeledExpression: 84 | active: false 85 | ignoredLabels: [] 86 | LargeClass: 87 | active: true 88 | threshold: 600 89 | LongMethod: 90 | active: true 91 | threshold: 60 92 | LongParameterList: 93 | active: true 94 | functionThreshold: 6 95 | constructorThreshold: 7 96 | ignoreDefaultParameters: false 97 | ignoreDataClasses: true 98 | ignoreAnnotated: [] 99 | MethodOverloading: 100 | active: false 101 | threshold: 6 102 | NamedArguments: 103 | active: false 104 | threshold: 3 105 | NestedBlockDepth: 106 | active: true 107 | threshold: 4 108 | ReplaceSafeCallChainWithRun: 109 | active: false 110 | StringLiteralDuplication: 111 | active: false 112 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 113 | threshold: 3 114 | ignoreAnnotation: true 115 | excludeStringsWithLessThan5Characters: true 116 | ignoreStringsRegex: '$^' 117 | TooManyFunctions: 118 | active: true 119 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 120 | thresholdInFiles: 14 121 | thresholdInClasses: 14 122 | thresholdInInterfaces: 11 123 | thresholdInObjects: 11 124 | thresholdInEnums: 11 125 | ignoreDeprecated: false 126 | ignorePrivate: false 127 | ignoreOverridden: false 128 | 129 | coroutines: 130 | active: true 131 | GlobalCoroutineUsage: 132 | active: false 133 | RedundantSuspendModifier: 134 | active: false 135 | SuspendFunWithFlowReturnType: 136 | active: false 137 | 138 | empty-blocks: 139 | active: true 140 | EmptyCatchBlock: 141 | active: true 142 | allowedExceptionNameRegex: '_|(ignore|expected).*' 143 | EmptyClassBlock: 144 | active: true 145 | EmptyDefaultConstructor: 146 | active: true 147 | EmptyDoWhileBlock: 148 | active: true 149 | EmptyElseBlock: 150 | active: true 151 | EmptyFinallyBlock: 152 | active: true 153 | EmptyForBlock: 154 | active: true 155 | EmptyFunctionBlock: 156 | active: true 157 | ignoreOverridden: false 158 | EmptyIfBlock: 159 | active: true 160 | EmptyInitBlock: 161 | active: true 162 | EmptyKtFile: 163 | active: true 164 | EmptySecondaryConstructor: 165 | active: true 166 | EmptyTryBlock: 167 | active: true 168 | EmptyWhenBlock: 169 | active: true 170 | EmptyWhileBlock: 171 | active: true 172 | 173 | exceptions: 174 | active: true 175 | ExceptionRaisedInUnexpectedLocation: 176 | active: false 177 | methodNames: [toString, hashCode, equals, finalize] 178 | InstanceOfCheckForException: 179 | active: false 180 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 181 | NotImplementedDeclaration: 182 | active: false 183 | PrintStackTrace: 184 | active: false 185 | RethrowCaughtException: 186 | active: false 187 | ReturnFromFinally: 188 | active: false 189 | ignoreLabeled: false 190 | SwallowedException: 191 | active: false 192 | ignoredExceptionTypes: 193 | - InterruptedException 194 | - NumberFormatException 195 | - ParseException 196 | - MalformedURLException 197 | allowedExceptionNameRegex: '_|(ignore|expected).*' 198 | ThrowingExceptionFromFinally: 199 | active: false 200 | ThrowingExceptionInMain: 201 | active: false 202 | ThrowingExceptionsWithoutMessageOrCause: 203 | active: false 204 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 205 | exceptions: 206 | - IllegalArgumentException 207 | - IllegalStateException 208 | - IOException 209 | ThrowingNewInstanceOfSameException: 210 | active: false 211 | TooGenericExceptionCaught: 212 | active: true 213 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 214 | exceptionNames: 215 | - ArrayIndexOutOfBoundsException 216 | - Error 217 | - Exception 218 | - IllegalMonitorStateException 219 | - NullPointerException 220 | - IndexOutOfBoundsException 221 | - RuntimeException 222 | - Throwable 223 | allowedExceptionNameRegex: '_|(ignore|expected).*' 224 | TooGenericExceptionThrown: 225 | active: true 226 | exceptionNames: 227 | - Error 228 | - Exception 229 | - Throwable 230 | - RuntimeException 231 | 232 | formatting: 233 | active: true 234 | android: false 235 | autoCorrect: true 236 | AnnotationOnSeparateLine: 237 | active: false 238 | autoCorrect: true 239 | AnnotationSpacing: 240 | active: false 241 | autoCorrect: true 242 | ArgumentListWrapping: 243 | active: false 244 | autoCorrect: true 245 | ChainWrapping: 246 | active: true 247 | autoCorrect: true 248 | CommentSpacing: 249 | active: true 250 | autoCorrect: true 251 | EnumEntryNameCase: 252 | active: false 253 | autoCorrect: true 254 | Filename: 255 | active: true 256 | FinalNewline: 257 | active: true 258 | autoCorrect: true 259 | insertFinalNewLine: true 260 | ImportOrdering: 261 | active: false 262 | autoCorrect: true 263 | layout: 'idea' 264 | Indentation: 265 | active: false 266 | autoCorrect: true 267 | indentSize: 4 268 | continuationIndentSize: 4 269 | MaximumLineLength: 270 | active: true 271 | maxLineLength: 120 272 | ModifierOrdering: 273 | active: true 274 | autoCorrect: true 275 | MultiLineIfElse: 276 | active: true 277 | autoCorrect: true 278 | NoBlankLineBeforeRbrace: 279 | active: true 280 | autoCorrect: true 281 | NoConsecutiveBlankLines: 282 | active: true 283 | autoCorrect: true 284 | NoEmptyClassBody: 285 | active: true 286 | autoCorrect: true 287 | NoEmptyFirstLineInMethodBlock: 288 | active: false 289 | autoCorrect: true 290 | NoLineBreakAfterElse: 291 | active: true 292 | autoCorrect: true 293 | NoLineBreakBeforeAssignment: 294 | active: true 295 | autoCorrect: true 296 | NoMultipleSpaces: 297 | active: true 298 | autoCorrect: true 299 | NoSemicolons: 300 | active: true 301 | autoCorrect: true 302 | NoTrailingSpaces: 303 | active: true 304 | autoCorrect: true 305 | NoUnitReturn: 306 | active: true 307 | autoCorrect: true 308 | NoUnusedImports: 309 | active: true 310 | autoCorrect: true 311 | NoWildcardImports: 312 | active: true 313 | PackageName: 314 | active: true 315 | autoCorrect: true 316 | ParameterListWrapping: 317 | active: true 318 | autoCorrect: true 319 | indentSize: 4 320 | SpacingAroundColon: 321 | active: true 322 | autoCorrect: true 323 | SpacingAroundComma: 324 | active: true 325 | autoCorrect: true 326 | SpacingAroundCurly: 327 | active: true 328 | autoCorrect: true 329 | SpacingAroundDot: 330 | active: true 331 | autoCorrect: true 332 | SpacingAroundDoubleColon: 333 | active: false 334 | autoCorrect: true 335 | SpacingAroundKeyword: 336 | active: true 337 | autoCorrect: true 338 | SpacingAroundOperators: 339 | active: true 340 | autoCorrect: true 341 | SpacingAroundParens: 342 | active: true 343 | autoCorrect: true 344 | SpacingAroundRangeOperator: 345 | active: true 346 | autoCorrect: true 347 | SpacingBetweenDeclarationsWithAnnotations: 348 | active: false 349 | autoCorrect: true 350 | SpacingBetweenDeclarationsWithComments: 351 | active: false 352 | autoCorrect: true 353 | StringTemplate: 354 | active: true 355 | autoCorrect: true 356 | 357 | naming: 358 | active: true 359 | ClassNaming: 360 | active: true 361 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 362 | classPattern: '[A-Z][a-zA-Z0-9]*' 363 | ConstructorParameterNaming: 364 | active: true 365 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 366 | parameterPattern: '[a-z][A-Za-z0-9]*' 367 | privateParameterPattern: '[a-z][A-Za-z0-9]*' 368 | excludeClassPattern: '$^' 369 | ignoreOverridden: true 370 | EnumNaming: 371 | active: true 372 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 373 | enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' 374 | ForbiddenClassName: 375 | active: false 376 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 377 | forbiddenName: [] 378 | FunctionMaxLength: 379 | active: false 380 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 381 | maximumFunctionNameLength: 30 382 | FunctionMinLength: 383 | active: false 384 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 385 | minimumFunctionNameLength: 3 386 | FunctionNaming: 387 | active: true 388 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 389 | functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' 390 | excludeClassPattern: '$^' 391 | ignoreOverridden: true 392 | ignoreAnnotated: ['Composable'] 393 | FunctionParameterNaming: 394 | active: true 395 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 396 | parameterPattern: '[a-z][A-Za-z0-9]*' 397 | excludeClassPattern: '$^' 398 | ignoreOverridden: true 399 | InvalidPackageDeclaration: 400 | active: false 401 | rootPackage: '' 402 | MatchingDeclarationName: 403 | active: true 404 | mustBeFirst: true 405 | MemberNameEqualsClassName: 406 | active: true 407 | ignoreOverridden: true 408 | NonBooleanPropertyPrefixedWithIs: 409 | active: false 410 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 411 | ObjectPropertyNaming: 412 | active: true 413 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 414 | constantPattern: '[A-Za-z][_A-Za-z0-9]*' 415 | propertyPattern: '[A-Za-z][_A-Za-z0-9]*' 416 | privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' 417 | PackageNaming: 418 | active: true 419 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 420 | packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' 421 | TopLevelPropertyNaming: 422 | active: true 423 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 424 | constantPattern: '[A-Z][_A-Z0-9]*' 425 | propertyPattern: '[A-Za-z][_A-Za-z0-9]*' 426 | privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' 427 | VariableMaxLength: 428 | active: false 429 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 430 | maximumVariableNameLength: 64 431 | VariableMinLength: 432 | active: false 433 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 434 | minimumVariableNameLength: 1 435 | VariableNaming: 436 | active: true 437 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 438 | variablePattern: '[a-z][A-Za-z0-9]*' 439 | privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' 440 | excludeClassPattern: '$^' 441 | ignoreOverridden: true 442 | 443 | performance: 444 | active: true 445 | ArrayPrimitive: 446 | active: true 447 | ForEachOnRange: 448 | active: true 449 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 450 | SpreadOperator: 451 | active: true 452 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 453 | UnnecessaryTemporaryInstantiation: 454 | active: true 455 | 456 | potential-bugs: 457 | active: true 458 | Deprecation: 459 | active: false 460 | DuplicateCaseInWhenExpression: 461 | active: true 462 | EqualsAlwaysReturnsTrueOrFalse: 463 | active: true 464 | EqualsWithHashCodeExist: 465 | active: true 466 | ExplicitGarbageCollectionCall: 467 | active: true 468 | HasPlatformType: 469 | active: false 470 | IgnoredReturnValue: 471 | active: false 472 | restrictToAnnotatedMethods: true 473 | returnValueAnnotations: ['*.CheckReturnValue', '*.CheckResult'] 474 | ImplicitDefaultLocale: 475 | active: false 476 | ImplicitUnitReturnType: 477 | active: false 478 | allowExplicitReturnType: true 479 | InvalidRange: 480 | active: true 481 | IteratorHasNextCallsNextMethod: 482 | active: true 483 | IteratorNotThrowingNoSuchElementException: 484 | active: true 485 | LateinitUsage: 486 | active: false 487 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 488 | excludeAnnotatedProperties: [] 489 | ignoreOnClassesPattern: '' 490 | MapGetWithNotNullAssertionOperator: 491 | active: false 492 | MissingWhenCase: 493 | active: true 494 | allowElseExpression: true 495 | NullableToStringCall: 496 | active: false 497 | RedundantElseInWhen: 498 | active: true 499 | UnconditionalJumpStatementInLoop: 500 | active: false 501 | UnnecessaryNotNullOperator: 502 | active: false 503 | UnnecessarySafeCall: 504 | active: false 505 | UnreachableCode: 506 | active: true 507 | UnsafeCallOnNullableType: 508 | active: true 509 | UnsafeCast: 510 | active: false 511 | UselessPostfixExpression: 512 | active: false 513 | WrongEqualsTypeParameter: 514 | active: true 515 | 516 | style: 517 | active: true 518 | ClassOrdering: 519 | active: false 520 | CollapsibleIfStatements: 521 | active: false 522 | DataClassContainsFunctions: 523 | active: false 524 | conversionFunctionPrefix: 'to' 525 | DataClassShouldBeImmutable: 526 | active: false 527 | EqualsNullCall: 528 | active: true 529 | EqualsOnSignatureLine: 530 | active: false 531 | ExplicitCollectionElementAccessMethod: 532 | active: false 533 | ExplicitItLambdaParameter: 534 | active: false 535 | ExpressionBodySyntax: 536 | active: false 537 | includeLineWrapping: false 538 | ForbiddenComment: 539 | active: true 540 | values: ['TODO:', 'FIXME:', 'STOPSHIP:'] 541 | allowedPatterns: '' 542 | ForbiddenImport: 543 | active: false 544 | imports: [] 545 | forbiddenPatterns: '' 546 | ForbiddenMethodCall: 547 | active: false 548 | methods: ['kotlin.io.println', 'kotlin.io.print'] 549 | ForbiddenPublicDataClass: 550 | active: false 551 | ignorePackages: ['*.internal', '*.internal.*'] 552 | ForbiddenVoid: 553 | active: false 554 | ignoreOverridden: false 555 | ignoreUsageInGenerics: false 556 | FunctionOnlyReturningConstant: 557 | active: true 558 | ignoreOverridableFunction: true 559 | excludedFunctions: 'describeContents' 560 | excludeAnnotatedFunction: ['dagger.Provides'] 561 | LibraryCodeMustSpecifyReturnType: 562 | active: true 563 | LibraryEntitiesShouldNotBePublic: 564 | active: false 565 | LoopWithTooManyJumpStatements: 566 | active: true 567 | maxJumpCount: 1 568 | MagicNumber: 569 | active: true 570 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 571 | ignoreNumbers: ['-1', '0', '1', '2'] 572 | ignoreHashCodeFunction: true 573 | ignorePropertyDeclaration: false 574 | ignoreLocalVariableDeclaration: false 575 | ignoreConstantDeclaration: true 576 | ignoreCompanionObjectPropertyDeclaration: true 577 | ignoreAnnotation: false 578 | ignoreNamedArgument: true 579 | ignoreEnums: false 580 | ignoreRanges: false 581 | MandatoryBracesIfStatements: 582 | active: false 583 | MandatoryBracesLoops: 584 | active: false 585 | MaxLineLength: 586 | active: true 587 | maxLineLength: 120 588 | excludePackageStatements: true 589 | excludeImportStatements: true 590 | excludeCommentStatements: false 591 | MayBeConst: 592 | active: true 593 | ModifierOrder: 594 | active: true 595 | NestedClassesVisibility: 596 | active: false 597 | NewLineAtEndOfFile: 598 | active: true 599 | NoTabs: 600 | active: false 601 | OptionalAbstractKeyword: 602 | active: true 603 | OptionalUnit: 604 | active: false 605 | OptionalWhenBraces: 606 | active: false 607 | PreferToOverPairSyntax: 608 | active: false 609 | ProtectedMemberInFinalClass: 610 | active: true 611 | RedundantExplicitType: 612 | active: false 613 | RedundantHigherOrderMapUsage: 614 | active: false 615 | RedundantVisibilityModifierRule: 616 | active: false 617 | ReturnCount: 618 | active: true 619 | max: 2 620 | excludedFunctions: 'equals' 621 | excludeLabeled: false 622 | excludeReturnFromLambda: true 623 | excludeGuardClauses: false 624 | SafeCast: 625 | active: true 626 | SerialVersionUIDInSerializableClass: 627 | active: false 628 | SpacingBetweenPackageAndImports: 629 | active: false 630 | ThrowsCount: 631 | active: true 632 | max: 2 633 | TrailingWhitespace: 634 | active: false 635 | UnderscoresInNumericLiterals: 636 | active: false 637 | acceptableDecimalLength: 5 638 | UnnecessaryAbstractClass: 639 | active: true 640 | excludeAnnotatedClasses: ['dagger.Module'] 641 | UnnecessaryAnnotationUseSiteTarget: 642 | active: false 643 | UnnecessaryApply: 644 | active: false 645 | UnnecessaryInheritance: 646 | active: true 647 | UnnecessaryLet: 648 | active: false 649 | UnnecessaryParentheses: 650 | active: false 651 | UntilInsteadOfRangeTo: 652 | active: false 653 | UnusedImports: 654 | active: false 655 | UnusedPrivateClass: 656 | active: true 657 | UnusedPrivateMember: 658 | active: false 659 | allowedNames: '(_|ignored|expected|serialVersionUID)' 660 | UseArrayLiteralsInAnnotations: 661 | active: false 662 | UseCheckNotNull: 663 | active: false 664 | UseCheckOrError: 665 | active: false 666 | UseDataClass: 667 | active: false 668 | excludeAnnotatedClasses: [] 669 | allowVars: false 670 | UseEmptyCounterpart: 671 | active: false 672 | UseIfEmptyOrIfBlank: 673 | active: false 674 | UseIfInsteadOfWhen: 675 | active: false 676 | UseRequire: 677 | active: false 678 | UseRequireNotNull: 679 | active: false 680 | UselessCallOnNotNull: 681 | active: true 682 | UtilityClassWithPublicConstructor: 683 | active: true 684 | VarCouldBeVal: 685 | active: false 686 | WildcardImport: 687 | active: true 688 | excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] 689 | excludeImports: ['java.util.*', 'kotlinx.android.synthetic.*'] 690 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Mar 01 18:06:48 CET 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /screenshots/scrn1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/screenshots/scrn1.png -------------------------------------------------------------------------------- /screenshots/summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gs-ts/TrackMyPath/52b11c0dc497dd873d1213c2c9df355fa163f893/screenshots/summary.png -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------