├── .github └── workflows │ └── android-pipeline.yml ├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ ├── AndroidManifest.xml │ ├── assets │ │ └── long_message.txt │ └── java │ │ └── com │ │ └── atiurin │ │ └── espressoguide │ │ ├── IntegrationTest.kt │ │ ├── framework │ │ ├── CustomActivityTestRule.kt │ │ ├── idlingresource │ │ │ ├── BaseIdlingResource.kt │ │ │ └── IdlingUtils.kt │ │ └── reporting │ │ │ ├── ScreenshotLifecycleListener.kt │ │ │ ├── Step.kt │ │ │ └── WindowHierarchyDumpListener.kt │ │ ├── pages │ │ ├── BlacklistPage.kt │ │ ├── ChatPage.kt │ │ └── FriendsListPage.kt │ │ └── tests │ │ ├── AdvancedEspressoTest.kt │ │ ├── BadDataPreparationTest.kt │ │ ├── BaseTest.kt │ │ ├── BlacklistTests.kt │ │ ├── ChatPageTest.kt │ │ ├── DemoEspressoTest.kt │ │ ├── SetUpTearDownDemoTest.kt │ │ └── SimpleEspressoTest.kt │ ├── debug │ └── AndroidManifest.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── atiurin │ │ │ └── espressoguide │ │ │ ├── Logger.kt │ │ │ ├── MyApplication.kt │ │ │ ├── activity │ │ │ ├── ChatActivity.kt │ │ │ ├── LoginActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── ProfileActivity.kt │ │ │ ├── SettingsActivity.kt │ │ │ └── SplashActivity.kt │ │ │ ├── adapters │ │ │ ├── ContactAdapter.kt │ │ │ ├── MessageAdapter.kt │ │ │ └── SettingsAdapter.kt │ │ │ ├── async │ │ │ ├── ContactsPresenter.kt │ │ │ ├── Either.kt │ │ │ ├── GetBlacklist.kt │ │ │ ├── GetContacts.kt │ │ │ └── UseCase.kt │ │ │ ├── core │ │ │ └── settings │ │ │ │ └── SettingsItem.kt │ │ │ ├── data │ │ │ ├── Tags.kt │ │ │ ├── entities │ │ │ │ ├── Contact.kt │ │ │ │ ├── Message.kt │ │ │ │ └── User.kt │ │ │ ├── loaders │ │ │ │ └── MessageLoader.kt │ │ │ └── repositories │ │ │ │ ├── ContactsRepositoty.kt │ │ │ │ ├── MessageRepository.kt │ │ │ │ └── Storage.kt │ │ │ ├── fragment │ │ │ └── settings │ │ │ │ ├── BlacklistFragment.kt │ │ │ │ ├── FragmentInfoContainer.kt │ │ │ │ ├── SettingsFragment.kt │ │ │ │ └── SettingsFragmentNavigator.kt │ │ │ ├── idlingresources │ │ │ ├── AbstractIdlingResource.kt │ │ │ ├── AppIdlingResource.kt │ │ │ ├── IdlingResourses.kt │ │ │ └── IdlingScope.kt │ │ │ ├── managers │ │ │ ├── AccountManager.kt │ │ │ └── PrefsManager.kt │ │ │ └── view │ │ │ └── CircleImageView.java │ └── res │ │ ├── drawable-anydpi │ │ ├── ic_account.xml │ │ ├── ic_attach_file.xml │ │ ├── ic_exit.xml │ │ ├── ic_messages.xml │ │ └── ic_send.xml │ │ ├── drawable-hdpi │ │ ├── ic_account.png │ │ ├── ic_attach_file.png │ │ ├── ic_exit.png │ │ ├── ic_messages.png │ │ └── ic_send.png │ │ ├── drawable-mdpi │ │ ├── ic_account.png │ │ ├── ic_attach_file.png │ │ ├── ic_exit.png │ │ ├── ic_messages.png │ │ └── ic_send.png │ │ ├── drawable-v24 │ │ ├── chandler.png │ │ ├── ic_launcher_foreground.xml │ │ ├── joey.png │ │ └── ross.png │ │ ├── drawable-xhdpi │ │ ├── ic_account.png │ │ ├── ic_attach_file.png │ │ ├── ic_exit.png │ │ ├── ic_messages.png │ │ └── ic_send.png │ │ ├── drawable-xxhdpi │ │ ├── ic_account.png │ │ ├── ic_attach_file.png │ │ ├── ic_exit.png │ │ ├── ic_messages.png │ │ └── ic_send.png │ │ ├── drawable │ │ ├── background_splash.xml │ │ ├── circle.xml │ │ ├── default_avatar.png │ │ ├── gunther.png │ │ ├── ic_launcher_background.xml │ │ ├── ic_menu_camera.xml │ │ ├── ic_menu_gallery.xml │ │ ├── ic_menu_manage.xml │ │ ├── ic_menu_send.xml │ │ ├── ic_menu_share.xml │ │ ├── ic_menu_slideshow.xml │ │ ├── img.xml │ │ ├── janice.png │ │ ├── monica.png │ │ ├── phoebe.png │ │ ├── rachel.png │ │ └── side_nav_bar.xml │ │ ├── layout │ │ ├── activity_chat.xml │ │ ├── activity_login.xml │ │ ├── activity_main.xml │ │ ├── activity_profile.xml │ │ ├── activity_settings.xml │ │ ├── app_bar_main.xml │ │ ├── content_main.xml │ │ ├── fragment_blacklist.xml │ │ ├── fragment_settings_list.xml │ │ ├── friends_list_item.xml │ │ ├── message_item.xml │ │ ├── my_text_view.xml │ │ ├── nav_header_main.xml │ │ └── settings_list_item.xml │ │ ├── menu │ │ ├── activity_main_drawer.xml │ │ ├── blacklist_menu.xml │ │ └── main.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-ru │ │ └── strings.xml │ │ ├── values-v21 │ │ └── styles.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── atiurin │ └── espressoguide │ ├── MockitoTest.kt │ ├── RobolectricTest.kt │ ├── SimpleUnitTest.kt │ └── UnitTestSuite.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── prepare-emulator.sh ├── settings.gradle └── sonar-project.properties /.github/workflows/android-pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | run_androidTests: 11 | runs-on: macos-latest 12 | strategy: 13 | matrix: 14 | api-level: [28] 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: run tests 20 | uses: reactivecircus/android-emulator-runner@v2 21 | with: 22 | api-level: ${{ matrix.api-level }} 23 | script: ./gradlew connectedCheck --stacktrace 24 | profile: pixel 25 | -------------------------------------------------------------------------------- /.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 | /.idea 11 | .idea 12 | allure* 13 | .DS_Store 14 | /build 15 | /captures 16 | .externalNativeBuild 17 | /.idea/ 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # espresso-guide 2 | 3 | This app is created to demonstrate different approaches of android automation tests. 4 | 5 | There are: 6 | 1. Unit tests with Robolectric and Mockito. 7 | 2. Simple integration test. 8 | 3. Simple and advanced espresso tests. 9 | 10 | ## Ultron framework 11 | 12 | All advanced actions and assertions in this sample project are executed using [Ultron](https://github.com/alex-tiurin/ultron) framework. 13 | 14 | ## Deep dive into espresso. 15 | 16 | **It will be really useful if you read this [README](https://github.com/alex-tiurin/ultron) firstly** 17 | 18 | In **EspressoGuide** you can find: 19 | 20 | 1. A page object approach for Espresso tests (look at the description of page object classes [here](https://github.com/alex-tiurin/espresso-guide/tree/master/app/src/androidTest/java/com/atiurin/espressoguide/pages)). 21 | 2. A new style of Espresso test using [Ultron](https://github.com/alex-tiurin/ultron) . 22 | The best example of how these tests looks like you can find in class [DemoEspressoTest](https://github.com/alex-tiurin/espresso-guide/blob/master/app/src/androidTest/java/com/atiurin/espressoguide/tests/DemoEspressoTest.kt) 23 | 3. A new great approach of how to work with RecyclerView lists (look inside page object classes again). 24 | 4. A new safe and simple way of using IdlingResources (look at [BaseTest](https://github.com/alex-tiurin/espresso-guide/blob/master/app/src/androidTest/java/com/atiurin/espressoguide/tests/BaseTest.kt), 25 | [IdlingUtils](https://github.com/alex-tiurin/espresso-guide/blob/master/app/src/androidTest/java/com/atiurin/espressoguide/framework/IdlingUtils.kt), and all things inside [idlingresources package](https://github.com/alex-tiurin/espresso-guide/tree/master/app/src/main/java/com/atiurin/espressoguide/idlingresources)). 26 | 5. How to configure allure report for a project. 27 | 6. How to find UI elements with **view.tag** value. This way of searching an element on the screen is really useful when resource id, content description and text are not applicable. 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'jacoco' 5 | apply plugin: 'org.sonarqube' 6 | 7 | android { 8 | compileSdkVersion 31 9 | defaultConfig { 10 | applicationId "com.atiurin.espressoguide" 11 | minSdkVersion 23 12 | targetSdkVersion 31 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "io.qameta.allure.android.runners.AllureAndroidJUnitRunner" 16 | } 17 | packagingOptions { 18 | exclude 'META-INF/DEPENDENCIES' 19 | exclude 'META-INF/LICENSE' 20 | exclude 'META-INF/LICENSE.txt' 21 | exclude 'META-INF/license.txt' 22 | exclude 'META-INF/NOTICE' 23 | exclude 'META-INF/NOTICE.txt' 24 | exclude 'META-INF/notice.txt' 25 | exclude 'META-INF/ASL2.0' 26 | exclude 'META-INF/AL2.0' 27 | exclude 'META-INF/LGPL2.1' 28 | exclude("META-INF/*.kotlin_module") 29 | } 30 | 31 | buildTypes { 32 | release { 33 | minifyEnabled false 34 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 35 | } 36 | debug { 37 | testCoverageEnabled = true 38 | } 39 | } 40 | testOptions { 41 | animationsDisabled true 42 | 43 | unitTests { 44 | includeAndroidResources = true 45 | } 46 | execution 'ANDROIDX_TEST_ORCHESTRATOR' 47 | } 48 | compileOptions { 49 | sourceCompatibility JavaVersion.VERSION_1_8 50 | targetCompatibility JavaVersion.VERSION_1_8 51 | } 52 | // For Kotlin projects 53 | kotlinOptions { 54 | jvmTarget = "1.8" 55 | } 56 | } 57 | 58 | sonarqube { 59 | properties { 60 | property "sonar.projectName", "espressoguide" 61 | property "sonar.projectBaseDir", "." 62 | property "sonar.source", "app/src/main/java" 63 | property "sonar.tests", "app/src/test/java, app/src/androidTest/java" 64 | property "sonar.java.test.binaries", "app/build/intermediates/class/debug" 65 | property "sonar.jacoco.reportPath", "app/build/jacoco/testDebugUnitTest.exec" 66 | property "sonar.java.coveragePlugin", "jacoco" 67 | } 68 | } 69 | jacoco { 70 | toolVersion = '0.8.0' 71 | } 72 | dependencies { 73 | implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.30" 74 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4" 75 | implementation 'androidx.appcompat:appcompat:1.2.0' 76 | implementation 'androidx.core:core-ktx:1.3.2' 77 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 78 | implementation 'com.google.android.material:material:1.2.1' 79 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 80 | implementation 'androidx.recyclerview:recyclerview:1.1.0' 81 | testImplementation 'junit:junit:4.13' 82 | //noinspection GradleCompatible 83 | implementation 'com.android.support:cardview-v7:28.0.0' 84 | implementation "androidx.fragment:fragment-ktx:1.2.5" 85 | // AndroidJUnitRunner and JUnit Rules 86 | 87 | testImplementation "org.robolectric:robolectric:4.4" 88 | testImplementation "androidx.test:core:1.3.0" 89 | testImplementation 'org.mockito:mockito-core:3.4.0' 90 | androidTestImplementation 'com.google.truth:truth:1.0' 91 | 92 | androidTestImplementation 'androidx.test:core:1.3.0' 93 | androidTestImplementation 'androidx.test:runner:1.3.0' 94 | androidTestImplementation 'androidx.test:rules:1.3.0' 95 | 96 | // Assertions 97 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 98 | androidTestImplementation 'androidx.test.ext:truth:1.3.0' 99 | 100 | // Espresso dependencies 101 | androidTestImplementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) // allure artifacts 102 | androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' 103 | androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' 104 | androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0' 105 | androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' 106 | androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' 107 | androidTestImplementation 'com.google.code.gson:gson:2.8.6' 108 | androidTestImplementation 'com.microsoft.appcenter:espresso-test-extension:1.4' 109 | androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' 110 | 111 | //ultron 112 | androidTestImplementation "com.atiurin:ultron:2.0.0-alpha02" 113 | //Allure 114 | androidTestImplementation "io.qameta.allure:allure-kotlin-model:2.2.6" 115 | androidTestImplementation "io.qameta.allure:allure-kotlin-commons:2.2.6" 116 | androidTestImplementation "io.qameta.allure:allure-kotlin-junit4:2.2.6" 117 | androidTestImplementation "io.qameta.allure:allure-kotlin-android:2.2.6" 118 | //Android Test Orchestrator 119 | androidTestUtil 'androidx.test:orchestrator:1.3.0' 120 | } 121 | 122 | task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest', 'createDebugCoverageReport']) { 123 | reports { 124 | xml.enabled = true 125 | html.enabled = true 126 | } 127 | 128 | def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'] 129 | getSourceDirectories().setFrom("$project.projectDir/src/main/java") 130 | getClassDirectories().setFrom(fileTree(dir: "$project.buildDir/tmp/kotlin-classes/debug", excludes: fileFilter)) 131 | getExecutionData().setFrom(fileTree(dir: project.buildDir, includes: [ 132 | 'jacoco/testDebugUnitTest.exec', 'outputs/code-coverage/connected/*coverage.ec' 133 | ])) 134 | } -------------------------------------------------------------------------------- /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/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/androidTest/assets/long_message.txt: -------------------------------------------------------------------------------- 1 | Heisenbug 2020 Piter is a large technical conference that takes place this summer 2020. 2 | 3 | Heisenbug conference brings together more than 1000 professionals in the field of quality assurance. These professionals are testers, programmers developing tests for their code, engineers in automated and load testing, and team leads willing to improve testing efficiency in their projects. All speakers talk about the most important, practical and hardcore things on software testing: 4 | 5 | Automation testing; 6 | Manual testing; 7 | Load testing, performance testing, benchmarking; 8 | Testing of distributed systems; 9 | Testing of mobile applications; 10 | UX, Security, A/B testing; 11 | Code analysis and its tools; 12 | Tools and testing environment; 13 | Test frameworks best practices; 14 | Testing of compilers, nuclear power plants, and so on. 15 | Our focus is on technological aspect of testing, and if you are not that familiar with it yet, it's a valid reason to attend the conference. We guarantee that there's no Agile, Scrum and team management stuff. 16 | 17 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/IntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import com.atiurin.espressoguide.managers.AccountManager 5 | import org.junit.Assert 6 | import org.junit.Ignore 7 | import org.junit.Test 8 | 9 | class IntegrationTest{ 10 | @Test 11 | @Ignore 12 | fun testLoginWithValidData() { 13 | val validPassword = "1234" 14 | val validUserName = "joey" 15 | val context = InstrumentationRegistry.getInstrumentation().targetContext 16 | val manager = AccountManager(context) 17 | manager.logout() 18 | manager.login(validUserName, validPassword) 19 | Assert.assertTrue(manager.isLoggedIn()) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/framework/CustomActivityTestRule.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.framework 2 | 3 | import android.app.Activity 4 | import androidx.test.rule.ActivityTestRule 5 | import com.atiurin.espressoguide.Logger 6 | 7 | open class CustomActivityTestRule : ActivityTestRule { 8 | constructor(activityClass: Class) : super(activityClass) 9 | constructor(activityClass: Class, initialTouchMode: Boolean, launchActivity: Boolean) : super(activityClass, initialTouchMode, launchActivity) 10 | 11 | override fun beforeActivityLaunched() { 12 | super.beforeActivityLaunched() 13 | Logger.life("beforeActivityLaunched") 14 | } 15 | 16 | override fun afterActivityLaunched() { 17 | super.afterActivityLaunched() 18 | Logger.life("afterActivityLaunched") 19 | } 20 | 21 | override fun finishActivity() { 22 | super.finishActivity() 23 | Logger.life("finishActivity") 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/framework/idlingresource/BaseIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.framework.idlingresource 2 | 3 | import androidx.test.espresso.IdlingResource 4 | import com.atiurin.espressoguide.Logger 5 | import com.atiurin.espressoguide.idlingresources.AppIdlingResource 6 | 7 | class BaseIdlingResource(private val appIdlingResource: AppIdlingResource) : IdlingResource, AppIdlingResource by appIdlingResource { 8 | override fun isIdleNow(): Boolean { 9 | return appIdlingResource.isIdle() 10 | } 11 | 12 | override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { 13 | Logger.debug("Idling. registerIdleTransitionCallback $name") 14 | appIdlingResource.registerIdleTransitionCallback(BaseResourceCallback(callback)) 15 | } 16 | 17 | inner class BaseResourceCallback(private val callback: IdlingResource.ResourceCallback) : AppIdlingResource.AppResourceCallback, IdlingResource.ResourceCallback by callback { 18 | override fun onTransitionToIdleState() { 19 | Logger.debug("Idling. onTransitionToIdleState resource ${this@BaseIdlingResource.name}") 20 | callback.onTransitionToIdle() 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/framework/idlingresource/IdlingUtils.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.framework 2 | 3 | import com.atiurin.espressoguide.idlingresources.ContactsIdling 4 | import com.atiurin.espressoguide.idlingresources.IdlingScope 5 | import com.atiurin.espressoguide.idlingresources.MessagesIdling 6 | 7 | fun getDefaultIdlingScope(): IdlingScope { 8 | return object : IdlingScope { 9 | override val messagesIdling: MessagesIdling = MessagesIdling() 10 | override var contactsIdling: ContactsIdling = ContactsIdling() 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/framework/reporting/ScreenshotLifecycleListener.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.atiurin.espressoguide.framework.reporting 3 | 4 | import com.atiurin.ultron.core.common.Operation 5 | import com.atiurin.ultron.core.common.OperationResult 6 | import com.atiurin.ultron.core.config.UltronConfig.UiAutomator.Companion.uiDevice 7 | import com.atiurin.ultron.listeners.UltronLifecycleListener 8 | import io.qameta.allure.android.allureScreenshot 9 | 10 | class ScreenshotLifecycleListener : UltronLifecycleListener() { 11 | override fun afterFailure(operationResult: OperationResult) { 12 | takeScreenshot(operationResult.operation.name) 13 | } 14 | 15 | private fun takeScreenshot(name: String){ 16 | allureScreenshot(name, 10, 1f ) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/framework/reporting/Step.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.framework.reporting 2 | 3 | import android.util.Log 4 | import io.qameta.allure.kotlin.Allure.step 5 | 6 | inline fun step (description: String, crossinline action: () -> T): T { 7 | Log.d("Espresso step", "------------ $description ------------ ") 8 | return step(description) { 9 | action() 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/framework/reporting/WindowHierarchyDumpListener.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.framework.reporting 2 | 3 | import androidx.test.uiautomator.UiDevice 4 | import com.atiurin.ultron.core.common.Operation 5 | import com.atiurin.ultron.core.common.OperationResult 6 | import com.atiurin.ultron.listeners.UltronLifecycleListener 7 | import java.io.ByteArrayOutputStream 8 | import java.util.concurrent.TimeUnit 9 | 10 | class WindowHierarchyDumpListener(private val fileName: String = "window-hierarchy") : 11 | UltronLifecycleListener() { 12 | override fun afterFailure(operationResult: OperationResult) { 13 | dumpHierarchy() 14 | } 15 | 16 | private fun dumpHierarchy() { 17 | // val attachmentFile = createAttachmentFile().apply { 18 | // this.writeBytes(uiDevice.dumpWindowHierarchy()) 19 | // } 20 | // AllureAndroidLifecycle.addAttachment( 21 | // fileName, type = "text/xml", fileExtension = "xml", attachmentFile 22 | // ) 23 | } 24 | 25 | private fun UiDevice.dumpWindowHierarchy(): ByteArray = ByteArrayOutputStream().apply { 26 | waitForIdle(TimeUnit.SECONDS.toMillis(10)) 27 | dumpWindowHierarchy(this) 28 | }.toByteArray() 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/pages/BlacklistPage.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.pages 2 | 3 | import androidx.test.espresso.matcher.ViewMatchers.* 4 | import com.atiurin.espressoguide.R 5 | import com.atiurin.espressoguide.framework.reporting.step 6 | import com.atiurin.ultron.core.espresso.recyclerview.UltronRecyclerViewItem 7 | import com.atiurin.ultron.core.espresso.recyclerview.withRecyclerView 8 | import com.atiurin.ultron.page.Page 9 | import org.hamcrest.CoreMatchers.allOf 10 | 11 | object BlacklistPage : Page() { 12 | private val itemsList = withRecyclerView(R.id.recycler_blacklist) 13 | 14 | fun assertPageDisplayed() = apply { 15 | step("Assert blacklist page displayed") { 16 | itemsList.isDisplayed() 17 | } 18 | } 19 | 20 | fun assertContactDisplayed(name: String) = apply { 21 | step("Assert contact with name $name is displayed in blacklist") { 22 | getListItem(name).isDisplayed() 23 | } 24 | } 25 | 26 | private fun getListItem(name: String): BlacklistItem { 27 | return itemsList.getItem(hasDescendant(allOf(withId(R.id.tv_name), withText(name)))) 28 | } 29 | 30 | private fun getItemAtPosition(position: Int): BlacklistItem { 31 | return itemsList.getItem(position) 32 | } 33 | 34 | class BlacklistItem : UltronRecyclerViewItem() { 35 | val name by lazy { getChild(withId(R.id.tv_name)) } 36 | val status by lazy { getChild(withId(R.id.tv_status)) } 37 | val avatar by lazy { getChild(withId(R.id.avatar)) } 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/pages/ChatPage.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.pages 2 | 3 | import android.view.View 4 | import androidx.test.espresso.matcher.ViewMatchers 5 | import androidx.test.espresso.matcher.ViewMatchers.* 6 | import com.atiurin.espressoguide.Logger 7 | import com.atiurin.espressoguide.R 8 | import com.atiurin.espressoguide.data.entities.Contact 9 | import com.atiurin.espressoguide.framework.* 10 | import com.atiurin.espressoguide.framework.reporting.step 11 | import com.atiurin.ultron.core.espresso.UltronEspresso 12 | import com.atiurin.ultron.core.espresso.recyclerview.UltronRecyclerViewItem 13 | import com.atiurin.ultron.core.espresso.recyclerview.withRecyclerView 14 | import com.atiurin.ultron.extensions.click 15 | import com.atiurin.ultron.extensions.hasText 16 | import com.atiurin.ultron.extensions.isDisplayed 17 | import com.atiurin.ultron.extensions.typeText 18 | import com.atiurin.ultron.page.Page 19 | import org.hamcrest.Matcher 20 | import org.hamcrest.Matchers.allOf 21 | 22 | object ChatPage : Page() { 23 | lateinit var contact: Contact 24 | private val list = withRecyclerView(R.id.messages_list) 25 | private val clearHistoryBtn = withText("Clear history") 26 | private val inputMessageText = withId(R.id.message_input_text) 27 | private val sendMessageBtn = withId(R.id.send_button) 28 | 29 | private fun getMessageListItem(text: String): ChatRecyclerItem { 30 | return list.getItem(hasDescendant( 31 | allOf( 32 | withId(R.id.message_text), 33 | withText(text) 34 | ) 35 | ) 36 | ) 37 | } 38 | 39 | private fun getMessageListItemAtPosition(position: Int): ChatRecyclerItem { 40 | return list.getItem(position) 41 | } 42 | 43 | private fun getTitle(title: String): Matcher { 44 | return allOf(withId(R.id.toolbar_title), withText(title)) 45 | } 46 | 47 | class ChatRecyclerItem : UltronRecyclerViewItem() { 48 | val text by lazy { getChild(withId(R.id.message_text)) } 49 | } 50 | 51 | fun assertPageDisplayed() = apply { 52 | step("Assert chat page is displayed") { 53 | list.isDisplayed() 54 | inputMessageText.isDisplayed() 55 | } 56 | } 57 | 58 | fun assertChatTitle() = apply { 59 | step("Assert chat with contact '${contact.name}' has correct title") { 60 | getTitle(contact.name).isDisplayed() 61 | } 62 | } 63 | 64 | fun sendMessage(text: String) = apply { 65 | step("Send message with text '$text'") { 66 | inputMessageText.typeText(text) 67 | sendMessageBtn.click() 68 | getMessageListItem(text).text 69 | .isDisplayed() 70 | .hasText(text) 71 | } 72 | } 73 | 74 | fun clearHistory() = apply { 75 | step("Clear chat history") { 76 | UltronEspresso.openContextualActionModeOverflowMenu() 77 | clearHistoryBtn.click() 78 | } 79 | } 80 | 81 | fun assertMessageDisplayed(text: String) = apply { 82 | step("Assert message with text is displayed") { 83 | getMessageListItem(text).text 84 | .isDisplayed() 85 | .hasText(text) 86 | } 87 | } 88 | 89 | fun assertMessageTextAtPosition(position: Int, text: String) = apply { 90 | step("Assert message at position $position has text '$text' and displayed") { 91 | getMessageListItemAtPosition(position).text 92 | .hasText(text) 93 | .isDisplayed() 94 | } 95 | } 96 | } 97 | 98 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/pages/FriendsListPage.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.pages 2 | 3 | import androidx.test.espresso.matcher.ViewMatchers.* 4 | import com.atiurin.espressoguide.Logger 5 | import com.atiurin.espressoguide.R 6 | import com.atiurin.espressoguide.data.Tags 7 | import com.atiurin.espressoguide.data.entities.Contact 8 | import com.atiurin.espressoguide.framework.* 9 | import com.atiurin.espressoguide.framework.reporting.step 10 | import com.atiurin.ultron.core.espresso.recyclerview.UltronRecyclerViewItem 11 | import com.atiurin.ultron.core.espresso.recyclerview.withRecyclerView 12 | import com.atiurin.ultron.extensions.hasText 13 | import com.atiurin.ultron.page.Page 14 | import org.hamcrest.Matchers.`is` 15 | import org.hamcrest.Matchers.allOf 16 | 17 | object FriendsListPage : Page() { 18 | private val list = withRecyclerView(withTagValue(`is`(Tags.CONTACTS_LIST))) 19 | 20 | private fun getFriendListItem(title: String): FriendRecyclerItem { 21 | return list.getItem(hasDescendant(allOf(withId(R.id.tv_name), withText(title)))) 22 | } 23 | 24 | class FriendRecyclerItem : UltronRecyclerViewItem() { 25 | val name by lazy { getChild(withId(R.id.tv_name)) } 26 | val status by lazy { getChild(withId(R.id.tv_status)) } 27 | } 28 | 29 | fun assertPageDisplayed() = apply { 30 | step("Assert friends list page displayed") { 31 | list.isDisplayed() 32 | } 33 | } 34 | 35 | fun openChat(contact: Contact): ChatPage { 36 | return step("Open chat with friend '${contact.name}'") { 37 | getFriendListItem(contact.name).click() 38 | ChatPage { 39 | this.contact = contact 40 | assertPageDisplayed() 41 | assertChatTitle() 42 | } 43 | } 44 | } 45 | 46 | fun assertStatus(name: String, status: String) = apply { 47 | step("Assert friend with name '$name' has status '$status'") { 48 | getFriendListItem(name).status.hasText(status) 49 | } 50 | } 51 | 52 | fun assertName(nameText: String) = apply { 53 | step("Assert friend name '$nameText' in the right place") { 54 | getFriendListItem(nameText).name.hasText(nameText) 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/AdvancedEspressoTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import android.content.Intent 4 | import androidx.recyclerview.widget.RecyclerView 5 | import androidx.test.espresso.Espresso.onView 6 | import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu 7 | import androidx.test.espresso.action.ViewActions.click 8 | import androidx.test.espresso.action.ViewActions.typeText 9 | import androidx.test.espresso.assertion.ViewAssertions.matches 10 | import androidx.test.espresso.contrib.RecyclerViewActions 11 | import androidx.test.espresso.matcher.ViewMatchers.* 12 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 13 | import androidx.test.rule.ActivityTestRule 14 | import com.atiurin.espressoguide.R 15 | import com.atiurin.espressoguide.activity.MainActivity 16 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 17 | import com.atiurin.espressoguide.managers.AccountManager 18 | import org.hamcrest.Matchers.allOf 19 | import org.junit.Before 20 | import org.junit.Ignore 21 | import org.junit.Rule 22 | import org.junit.Test 23 | 24 | class AdvancedEspressoTest : BaseTest() { 25 | @Rule 26 | @JvmField 27 | val activityTestRule = ActivityTestRule(MainActivity::class.java, false, false) 28 | 29 | @Before 30 | fun backgroundLogin() { 31 | AccountManager(getInstrumentation().targetContext).login( 32 | CURRENT_USER.login, 33 | CURRENT_USER.password 34 | ) 35 | activityTestRule.launchActivity(Intent()) 36 | } 37 | 38 | /** 39 | * clear espresso test. it is hard to be maintained 40 | * Look at better approach [DemoEspressoTest] 41 | */ 42 | // @Ignore 43 | @Test 44 | fun clearEspressoTestWithRecyclerViewActions() { 45 | val messageText = "message4" 46 | val itemMatcher = hasDescendant(allOf(withId(R.id.tv_name), withText("Janice"))) 47 | onView(withId(R.id.recycler_friends)) 48 | .perform( 49 | RecyclerViewActions 50 | .actionOnItem(itemMatcher, click()) 51 | ) 52 | 53 | openActionBarOverflowOrOptionsMenu(getInstrumentation().context) 54 | onView(withText("Clear history")).perform(click()) 55 | onView(withId(R.id.message_input_text)).perform(typeText(messageText)) 56 | onView(withId(R.id.send_button)).perform(click()) 57 | onView(withId(R.id.messages_list)) 58 | .perform( 59 | RecyclerViewActions 60 | .scrollTo(hasDescendant(withText(messageText))) 61 | ) 62 | onView( 63 | allOf( 64 | withText(messageText), 65 | withId(R.id.message_text) 66 | ) 67 | ).check(matches(isDisplayed())) 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/BadDataPreparationTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import android.content.Intent 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import androidx.test.rule.ActivityTestRule 6 | import com.atiurin.espressoguide.activity.ChatActivity 7 | import com.atiurin.espressoguide.activity.INTENT_CONTACT_ID_EXTRA_NAME 8 | import com.atiurin.espressoguide.data.entities.Message 9 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 10 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 11 | import com.atiurin.espressoguide.data.repositories.MessageRepository 12 | import com.atiurin.espressoguide.managers.AccountManager 13 | import com.atiurin.espressoguide.pages.ChatPage 14 | import org.junit.Before 15 | import org.junit.BeforeClass 16 | import org.junit.Test 17 | 18 | 19 | /** 20 | * This test class was written to demonstrate a BAD approach. Don't use it in you project. 21 | * To understand a good way of configuring test data look at [ChatPageTest] 22 | */ 23 | class BadDataPreparationTest : BaseTest() { 24 | companion object { 25 | private val contact = ContactsRepositoty.getContact(2) 26 | private val simpleMessage = Message(CURRENT_USER.id, contact.id, "SimpleText") 27 | private val specialCharsMessage = Message(CURRENT_USER.id, contact.id, "!@#$%^&*(){}:\",./<>?_+±§`~][") 28 | private val longMessage = Message(CURRENT_USER.id, contact.id, InstrumentationRegistry.getInstrumentation().context.assets.open("long_message.txt").reader().readText()) 29 | @BeforeClass 30 | @JvmStatic 31 | fun prepareData() { 32 | MessageRepository.clearChatMessages(contact.id) 33 | MessageRepository.addChatMessage(contact.id, longMessage) 34 | MessageRepository.addChatMessage(contact.id, simpleMessage) 35 | MessageRepository.addChatMessage(contact.id, specialCharsMessage) 36 | } 37 | } 38 | private val activityTestRule = ActivityTestRule(ChatActivity::class.java, false, false) 39 | 40 | @Before 41 | fun prepareConditions(){ 42 | AccountManager(InstrumentationRegistry.getInstrumentation().targetContext).login( 43 | CURRENT_USER.login, 44 | CURRENT_USER.password 45 | ) 46 | val intent = Intent().putExtra(INTENT_CONTACT_ID_EXTRA_NAME, contact.id) 47 | activityTestRule.launchActivity(intent) 48 | } 49 | 50 | @Test 51 | fun assertSimpleMessage() { 52 | ChatPage.assertMessageDisplayed(simpleMessage.text) 53 | } 54 | 55 | @Test 56 | fun assertSpecialMessage() { 57 | ChatPage.assertMessageDisplayed(specialCharsMessage.text) 58 | } 59 | @Test 60 | fun assertLongMessage() { 61 | ChatPage.assertMessageDisplayed(longMessage.text) 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/BaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import androidx.test.espresso.IdlingRegistry 4 | import com.atiurin.espressoguide.framework.getDefaultIdlingScope 5 | import com.atiurin.espressoguide.framework.idlingresource.BaseIdlingResource 6 | import com.atiurin.espressoguide.framework.reporting.ScreenshotLifecycleListener 7 | import com.atiurin.espressoguide.framework.reporting.WindowHierarchyDumpListener 8 | import com.atiurin.espressoguide.idlingresources.idling 9 | import com.atiurin.espressoguide.idlingresources.idlingContainer 10 | import com.atiurin.ultron.core.config.UltronConfig 11 | import com.atiurin.ultron.testlifecycle.rulesequence.RuleSequence 12 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUpRule 13 | import com.atiurin.ultron.testlifecycle.setupteardown.TearDownRule 14 | import org.junit.* 15 | 16 | abstract class BaseTest { 17 | //attach screenshot to allure report in case of failure 18 | //attach logcat to allure report in case of failure 19 | @get:Rule 20 | val ruleSequence = RuleSequence( 21 | SetUpRule().add { 22 | UltronConfig.Espresso.ESPRESSO_OPERATION_POLLING_TIMEOUT = 0L 23 | idlingContainer.set(getDefaultIdlingScope()) 24 | idling { IdlingRegistry.getInstance().register(BaseIdlingResource(contactsIdling)) } 25 | }, 26 | TearDownRule().add { 27 | idling { IdlingRegistry.getInstance().unregister(BaseIdlingResource(contactsIdling)) } 28 | } 29 | ) 30 | 31 | companion object { 32 | @BeforeClass 33 | @JvmStatic 34 | fun beforeClassBase() { 35 | UltronConfig.addGlobalListener(ScreenshotLifecycleListener()) 36 | UltronConfig.addGlobalListener(WindowHierarchyDumpListener()) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/BlacklistTests.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import androidx.test.rule.ActivityTestRule 4 | import com.atiurin.espressoguide.activity.SettingsActivity 5 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 6 | import com.atiurin.espressoguide.fragment.settings.BlacklistFragment 7 | import com.atiurin.espressoguide.fragment.settings.SettingsFragmentNavigator 8 | import com.atiurin.espressoguide.pages.BlacklistPage 9 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUp 10 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUpRule 11 | import io.qameta.allure.kotlin.junit4.DisplayName 12 | import org.junit.Test 13 | 14 | class BlacklistTests : BaseTest() { 15 | companion object { 16 | const val ADD_CONTACT_TO_BLACKLIST = "ADD_CONTACT_TO_BLACKLIST" 17 | const val CLEAR_BLACKLIST = "CLEAR_BLACKLIST" 18 | } 19 | 20 | val contact = ContactsRepositoty.getContact(2) 21 | 22 | private val activityRule = ActivityTestRule(SettingsActivity::class.java) 23 | private val setUpTearDownRule = SetUpRule() 24 | .add(CLEAR_BLACKLIST) { 25 | ContactsRepositoty.clearBlacklist() 26 | } 27 | .add(ADD_CONTACT_TO_BLACKLIST) { 28 | ContactsRepositoty.addToBlacklist(contact.id) 29 | } 30 | 31 | init { 32 | ruleSequence 33 | .add(setUpTearDownRule, activityRule) 34 | .addLast(SetUpRule().add { 35 | activityRule.runOnUiThread { 36 | SettingsFragmentNavigator.go(BlacklistFragment::class.java) 37 | } 38 | }) 39 | } 40 | 41 | @DisplayName("when contact in blacklist then it displayed in blacklist") 42 | @SetUp(CLEAR_BLACKLIST, ADD_CONTACT_TO_BLACKLIST) 43 | @Test 44 | fun testItemDisplayedInBlacklist() { 45 | BlacklistPage.assertContactDisplayed(contact.name) 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/ChatPageTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import android.content.Intent 4 | import androidx.test.core.app.ActivityScenario 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import com.atiurin.espressoguide.activity.ChatActivity 7 | import com.atiurin.espressoguide.activity.INTENT_CONTACT_ID_EXTRA_NAME 8 | import com.atiurin.espressoguide.data.entities.Message 9 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 10 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 11 | import com.atiurin.espressoguide.data.repositories.MessageRepository 12 | import com.atiurin.espressoguide.managers.AccountManager 13 | import com.atiurin.espressoguide.pages.ChatPage 14 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUp 15 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUpRule 16 | import org.junit.Test 17 | 18 | class ChatPageTest : BaseTest() { 19 | companion object { 20 | const val ADD_SIMPLE_MESSAGE = "simple_message" 21 | const val ADD_SPECIAL_CHARS_MESSAGE = "special_chars_message" 22 | const val ADD_LONG_MESSAGE = "long_message" 23 | } 24 | 25 | private val contact = ContactsRepositoty.getContact(2) 26 | private val simpleMessage = Message(CURRENT_USER.id, contact.id, "SimpleText") 27 | private val specialCharsMessage = 28 | Message(CURRENT_USER.id, contact.id, "!@#$%^&*(){}:\",./<>?_+±§`~][") 29 | private val longMessage = Message( 30 | CURRENT_USER.id, 31 | contact.id, 32 | InstrumentationRegistry.getInstrumentation().context.assets.open("long_message.txt") 33 | .reader().readText() 34 | ) 35 | private val setUpTearDownRule = SetUpRule() 36 | .add { MessageRepository.clearChatMessages(contact.id) } 37 | .add(ADD_SIMPLE_MESSAGE) { 38 | MessageRepository.addChatMessage(contact.id, simpleMessage) 39 | } 40 | .add(ADD_SPECIAL_CHARS_MESSAGE) { 41 | MessageRepository.addChatMessage(contact.id, specialCharsMessage) 42 | } 43 | .add(ADD_LONG_MESSAGE) { 44 | MessageRepository.addChatMessage(contact.id, longMessage) 45 | } 46 | .add { 47 | AccountManager(InstrumentationRegistry.getInstrumentation().targetContext).login( 48 | CURRENT_USER.login, 49 | CURRENT_USER.password 50 | ) 51 | val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ChatActivity::class.java).putExtra(INTENT_CONTACT_ID_EXTRA_NAME, contact.id) 52 | ActivityScenario.launch(intent) 53 | } 54 | 55 | init { 56 | ruleSequence.add(setUpTearDownRule) 57 | ChatPage.contact = contact 58 | } 59 | 60 | @Test 61 | @SetUp(ADD_SIMPLE_MESSAGE) 62 | fun assertSimpleMessage() { 63 | ChatPage.assertMessageDisplayed(simpleMessage.text) 64 | } 65 | 66 | @Test 67 | @SetUp(ADD_SPECIAL_CHARS_MESSAGE) 68 | fun assertSpecialCharsMessage() { 69 | ChatPage.assertMessageDisplayed(specialCharsMessage.text) 70 | } 71 | 72 | @Test 73 | @SetUp(ADD_LONG_MESSAGE) 74 | fun assertLongMessage() { 75 | ChatPage.assertMessageDisplayed(longMessage.text) 76 | } 77 | 78 | @Test 79 | fun assertChatTitle() { 80 | ChatPage.assertChatTitle() 81 | } 82 | 83 | @Test 84 | fun addNewMessage() { 85 | val messageText = "new message" 86 | ChatPage { 87 | sendMessage(messageText) 88 | assertMessageDisplayed(messageText) 89 | } 90 | } 91 | 92 | @Test 93 | @SetUp(ADD_SIMPLE_MESSAGE, ADD_SPECIAL_CHARS_MESSAGE) 94 | fun assertMessagePosition() { 95 | val messageText = "position message" 96 | val initialMaxPosition = MessageRepository.getChatMessagesCount(contact.id) - 1 97 | ChatPage { 98 | sendMessage(messageText) 99 | assertMessageTextAtPosition(initialMaxPosition + 1, messageText) 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/DemoEspressoTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import androidx.test.ext.junit.rules.ActivityScenarioRule 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import com.atiurin.espressoguide.activity.MainActivity 6 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 7 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 8 | import com.atiurin.espressoguide.managers.AccountManager 9 | import com.atiurin.espressoguide.pages.ChatPage 10 | import com.atiurin.espressoguide.pages.FriendsListPage 11 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUpRule 12 | import io.qameta.allure.kotlin.junit4.DisplayName 13 | import org.junit.* 14 | 15 | 16 | /** 17 | * There are several ways of how to use page object class. 18 | * Use the one you prefer 19 | */ 20 | class DemoEspressoTest : BaseTest() { 21 | private val contact = ContactsRepositoty.getContact("Chandler Bing") 22 | 23 | init { 24 | ruleSequence 25 | .add( 26 | SetUpRule() 27 | .add { 28 | //make login into app before test starts and activity is launched 29 | //to make sure that user is logged in when test starts 30 | AccountManager(InstrumentationRegistry.getInstrumentation().targetContext).login( 31 | CURRENT_USER.login, 32 | CURRENT_USER.password 33 | ) 34 | ChatPage.contact = contact 35 | }) 36 | .addLast(ActivityScenarioRule(MainActivity::class.java)) 37 | } 38 | 39 | @Test 40 | fun friendsItemCheck() { 41 | FriendsListPage { 42 | assertName("Janice") 43 | assertStatus("Janice", "Oh. My. God") 44 | } 45 | } 46 | 47 | @Test 48 | fun sendMessage() { 49 | val chatPage = FriendsListPage.openChat(contact) 50 | chatPage.clearHistory() 51 | chatPage.sendMessage("test message") 52 | } 53 | 54 | @Test 55 | fun checkMessagesPositionsInChat() { 56 | val firstMessage = "first message" 57 | val secondMessage = "second message" 58 | FriendsListPage { openChat(contact) } 59 | ChatPage { 60 | clearHistory() 61 | sendMessage(firstMessage) 62 | sendMessage(secondMessage) 63 | assertMessageTextAtPosition(0, firstMessage) 64 | assertMessageTextAtPosition(1, secondMessage) 65 | } 66 | } 67 | 68 | /** 69 | * Test should fail 70 | */ 71 | @DisplayName("Special failed test for allure report demo") 72 | @Test 73 | fun specialFailedTestForAllureReport() { 74 | val firstMessage = "first message" 75 | val secondMessage = "second message" 76 | val janiceContact = ContactsRepositoty.getContact("Janice") 77 | val chatPage = FriendsListPage.openChat(janiceContact) 78 | chatPage { 79 | clearHistory() 80 | sendMessage(firstMessage) 81 | sendMessage(secondMessage) 82 | assertMessageTextAtPosition(0, secondMessage) 83 | assertMessageTextAtPosition(1, firstMessage) 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/SetUpTearDownDemoTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import com.atiurin.espressoguide.activity.MainActivity 5 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 6 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 7 | import com.atiurin.espressoguide.framework.CustomActivityTestRule 8 | import com.atiurin.espressoguide.Logger 9 | import com.atiurin.espressoguide.managers.AccountManager 10 | import com.atiurin.espressoguide.pages.ChatPage 11 | import com.atiurin.espressoguide.pages.FriendsListPage 12 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUp 13 | import com.atiurin.ultron.testlifecycle.setupteardown.SetUpRule 14 | import com.atiurin.ultron.testlifecycle.setupteardown.TearDown 15 | import com.atiurin.ultron.testlifecycle.setupteardown.TearDownRule 16 | import org.junit.* 17 | 18 | class SetUpTearDownDemoTest : BaseTest() { 19 | lateinit var chatPage: ChatPage 20 | private val contact = ContactsRepositoty.getContact("Chandler Bing") 21 | 22 | companion object { 23 | const val FIRST_SETUP = "FirstSetup" 24 | const val SECOND_SETUP = "SecondSetup" 25 | const val FIRST_TEARDOWN = "FirstTearDown" 26 | const val SECOND_TEARDOWN = "SecondTearDown" 27 | } 28 | 29 | init { 30 | ruleSequence 31 | .addFirst( 32 | SetUpRule() 33 | .add { 34 | Logger.life("common setup") 35 | //make login into app before test starts and activity is launched 36 | //to make sure that user is logged in when test starts 37 | AccountManager(InstrumentationRegistry.getInstrumentation().targetContext).login( 38 | CURRENT_USER.login, 39 | CURRENT_USER.password 40 | ) 41 | } 42 | .add(FIRST_SETUP) { Logger.life("first setup") } 43 | .add(SECOND_SETUP) { Logger.life("second setup") } , 44 | TearDownRule() 45 | .add { Logger.life("common tear down") } 46 | .add(FIRST_TEARDOWN) { Logger.life("first tear down") } 47 | .add(SECOND_TEARDOWN) { Logger.life("second tear down") }) 48 | .addLast(CustomActivityTestRule(MainActivity::class.java)) 49 | } 50 | 51 | @Test 52 | fun friendsItemCheck() { 53 | Logger.life("test friendsItemCheck") 54 | FriendsListPage { 55 | assertName("Janice") 56 | assertStatus("Janice", "Oh. My. God") 57 | } 58 | } 59 | 60 | @SetUp(FIRST_SETUP) 61 | @TearDown(SECOND_TEARDOWN) 62 | @Test 63 | fun sendMessage() { 64 | Logger.life("test sendMessage") 65 | chatPage = FriendsListPage.openChat(contact) 66 | chatPage { 67 | clearHistory() 68 | sendMessage("test message") 69 | } 70 | 71 | } 72 | 73 | @SetUp(SECOND_SETUP, FIRST_SETUP) 74 | @TearDown(SECOND_TEARDOWN) 75 | @Test 76 | fun checkMessagesPositionsInChat() { 77 | Logger.life("test checkMessagesPositionsInChat") 78 | val firstMessage = "first message" 79 | val secondMessage = "second message" 80 | val chatPage = FriendsListPage.openChat(contact) 81 | chatPage { 82 | clearHistory() 83 | sendMessage(firstMessage) 84 | sendMessage(secondMessage) 85 | assertMessageTextAtPosition(0, firstMessage) 86 | assertMessageTextAtPosition(1, secondMessage) 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/atiurin/espressoguide/tests/SimpleEspressoTest.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.tests 2 | 3 | import android.content.Intent 4 | import androidx.test.espresso.Espresso.onView 5 | import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu 6 | import androidx.test.espresso.action.ViewActions.click 7 | import androidx.test.espresso.action.ViewActions.typeText 8 | import androidx.test.espresso.assertion.ViewAssertions.matches 9 | import androidx.test.espresso.matcher.ViewMatchers.* 10 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 11 | import androidx.test.rule.ActivityTestRule 12 | import com.atiurin.espressoguide.R 13 | import com.atiurin.espressoguide.activity.MainActivity 14 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 15 | import com.atiurin.espressoguide.managers.AccountManager 16 | import org.junit.Before 17 | import org.junit.Rule 18 | import org.junit.Test 19 | 20 | class SimpleEspressoTest : BaseTest() { 21 | @Rule 22 | @JvmField 23 | val activityTestRule = ActivityTestRule(MainActivity::class.java, false, false) 24 | 25 | @Before 26 | fun backgroundLogin() { 27 | //make login into app before test start and activity launched 28 | //to be sure that user is logged in when test start 29 | AccountManager(getInstrumentation().targetContext).login( 30 | CURRENT_USER.login, 31 | CURRENT_USER.password 32 | ) 33 | activityTestRule.launchActivity(Intent()) 34 | } 35 | 36 | /** 37 | * bad approach: chandler could be invisible (out of screen) 38 | in this case test will fail, look at better way in [AdvancedEspressoTest] 39 | */ 40 | @Test 41 | fun simpleEspressoTest_SendStringMessage() { 42 | val messageText = "message1" 43 | onView(withText("Chandler Bing")).perform(click()) 44 | openActionBarOverflowOrOptionsMenu(getInstrumentation().context) 45 | onView(withText("Clear history")).perform(click()) 46 | onView(withId(R.id.message_input_text)).perform(typeText(messageText)) 47 | onView(withId(R.id.send_button)).perform(click()) 48 | onView(withText(messageText)).check(matches(isDisplayed())) 49 | } 50 | 51 | @Test 52 | fun simpleEspressoTest_SendNumbers() { 53 | val messageText = "1234567890" 54 | onView(withText("Rachel Green")).perform(click()) 55 | openActionBarOverflowOrOptionsMenu(getInstrumentation().context) 56 | onView(withText("Clear history")).perform(click()) 57 | onView(withId(R.id.message_input_text)).perform(typeText(messageText)) 58 | onView(withId(R.id.send_button)).perform(click()) 59 | onView(withText(messageText)).check(matches(isDisplayed())) 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 34 | 37 | 40 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/Logger.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide 2 | 3 | import android.util.Log 4 | 5 | object Logger { 6 | private val LOG_TAG = "EspressoGuide" 7 | fun info(message: String) = Log.i(LOG_TAG, message) 8 | fun debug(message: String) = Log.d(LOG_TAG, message) 9 | fun error(message: String) = Log.e(LOG_TAG, message) 10 | fun warning(message: String) = Log.w(LOG_TAG, message) 11 | fun life(message: String) = Log.d("Life>>", message) 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | 6 | object MyApplication : Application() { 7 | override fun onCreate() { 8 | super.onCreate() 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/activity/ChatActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.activity 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.view.Menu 7 | import android.view.MenuItem 8 | import android.view.View 9 | import android.widget.* 10 | import androidx.appcompat.app.AppCompatActivity 11 | import androidx.appcompat.widget.Toolbar 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.RecyclerView 14 | import com.atiurin.espressoguide.view.CircleImageView 15 | import com.atiurin.espressoguide.R 16 | import com.atiurin.espressoguide.adapters.MessageAdapter 17 | import com.atiurin.espressoguide.data.entities.Contact 18 | import com.atiurin.espressoguide.data.entities.Message 19 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 20 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 21 | import com.atiurin.espressoguide.data.repositories.MessageRepository 22 | import com.atiurin.espressoguide.managers.AccountManager 23 | import com.google.android.material.snackbar.Snackbar 24 | 25 | const val INTENT_CONTACT_ID_EXTRA_NAME = "contactId" 26 | 27 | class ChatActivity : AppCompatActivity() { 28 | private lateinit var recyclerView: RecyclerView 29 | private lateinit var viewAdapter: MessageAdapter 30 | private lateinit var viewManager: RecyclerView.LayoutManager 31 | private lateinit var contact: Contact 32 | private val onItemClickListener: View.OnClickListener? = null 33 | override fun onCreate(savedInstanceState: Bundle?) { 34 | super.onCreate(savedInstanceState) 35 | val accountManager = AccountManager(applicationContext) 36 | if (!accountManager.isLoggedIn()) { 37 | val intent = Intent(applicationContext, LoginActivity::class.java) 38 | startActivity(intent) 39 | } 40 | setContentView(R.layout.activity_chat) 41 | val context = this 42 | //TOOLBAR 43 | val toolbar: Toolbar = findViewById(R.id.toolbar) 44 | toolbar.title = "" 45 | setSupportActionBar(toolbar) 46 | supportActionBar!!.setDisplayHomeAsUpEnabled(true) 47 | window.statusBarColor = getColor(R.color.colorPrimaryDark) 48 | val intent = intent 49 | val contactId = intent.getIntExtra(INTENT_CONTACT_ID_EXTRA_NAME, -1) 50 | val title = findViewById(R.id.toolbar_title) 51 | if (contactId < 0) { 52 | Log.d("EspressoGuide", "Something goes wrong!") 53 | } 54 | contact = ContactsRepositoty.getContact(contactId) 55 | title.text = contact.name 56 | val avatar = findViewById(R.id.toolbar_avatar) 57 | avatar.setImageDrawable(getDrawable(contact.avatar)) 58 | //message input area 59 | val messageInput = findViewById(R.id.message_input_text) 60 | val sendBtn = findViewById(R.id.send_button) 61 | val attachBtn = findViewById(R.id.attach_button) 62 | 63 | //recycler view and adapter 64 | viewManager = LinearLayoutManager(this) 65 | viewAdapter = MessageAdapter( 66 | ArrayList(), 67 | object : MessageAdapter.OnItemClickListener { 68 | override fun onItemClick(message: Message) { 69 | Log.w("EspressoGuid", "Clicked message ${message.text}") 70 | } 71 | 72 | override fun onItemLongClick(message: Message) { 73 | Log.w("EspressoGuid", "Long Clicked message ${message.text}") 74 | } 75 | }) 76 | recyclerView = findViewById(R.id.messages_list).apply { 77 | setHasFixedSize(true) 78 | layoutManager = viewManager 79 | adapter = viewAdapter 80 | } 81 | sendBtn.setOnClickListener { 82 | if (messageInput.text.isEmpty()) { 83 | Toast.makeText(context, "Type message text", Toast.LENGTH_LONG).show() 84 | } else { 85 | val mes = Message(CURRENT_USER.id, contactId, messageInput.text.toString()) 86 | val chatMessages = MessageRepository.getChatMessages(contactId) 87 | chatMessages.add(mes) 88 | updateAdapter(chatMessages) 89 | messageInput.setText("") 90 | recyclerView.smoothScrollToPosition(viewAdapter.itemCount - 1) 91 | } 92 | } 93 | attachBtn.setOnClickListener{ 94 | Snackbar.make(recyclerView, "Attach not implemented", Snackbar.LENGTH_LONG) 95 | .setAction("Action", null).show() 96 | } 97 | } 98 | 99 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 100 | menuInflater.inflate(R.menu.main, menu) 101 | return true 102 | } 103 | 104 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 105 | return when (item.itemId) { 106 | R.id.action_clear -> { 107 | MessageRepository.clearChatMessages(contact.id) 108 | updateAdapter(ArrayList()) 109 | true 110 | } 111 | R.id.action_add_blacklist -> { 112 | ContactsRepositoty.addToBlacklist(contact.id) 113 | true 114 | } 115 | else -> super.onOptionsItemSelected(item) 116 | } 117 | } 118 | 119 | override fun onSupportNavigateUp(): Boolean { 120 | onBackPressed() 121 | return true 122 | } 123 | 124 | override fun onResume() { 125 | super.onResume() 126 | viewAdapter.updateData(MessageRepository.getChatMessages(contact.id)) 127 | viewAdapter.notifyDataSetChanged() 128 | } 129 | 130 | private fun updateAdapter(list: MutableList) { 131 | viewAdapter.updateData(list) 132 | viewAdapter.notifyDataSetChanged() 133 | } 134 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/activity/LoginActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.activity 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.Gravity 6 | import android.widget.Button 7 | import android.widget.EditText 8 | import android.widget.Toast 9 | import androidx.appcompat.app.ActionBar 10 | import androidx.appcompat.app.AppCompatActivity 11 | import com.atiurin.espressoguide.R 12 | import com.atiurin.espressoguide.managers.AccountManager 13 | 14 | class LoginActivity : AppCompatActivity(){ 15 | lateinit var etUserName : EditText 16 | lateinit var etPassword : EditText 17 | lateinit var loginBtn : Button 18 | 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | setContentView(R.layout.activity_login) 22 | 23 | val bar = supportActionBar 24 | bar!!.title = "Login or Sign Up" 25 | 26 | etUserName = findViewById(R.id.et_username) 27 | etPassword = findViewById(R.id.et_password) 28 | loginBtn = findViewById(R.id.login_button) 29 | 30 | loginBtn.setOnClickListener{ 31 | val accountManager = AccountManager(applicationContext) 32 | val userName = etUserName.text.toString() 33 | val password = etPassword.text.toString() 34 | 35 | if (userName.isEmpty()){ 36 | with(etUserName){ 37 | setHint("Enter user name") 38 | setHintTextColor(resources.getColor(android.R.color.holo_red_dark)) 39 | } 40 | } 41 | if (password.isEmpty()){ 42 | with(etPassword){ 43 | setHint("Enter password") 44 | setHintTextColor(resources.getColor(android.R.color.holo_red_dark)) 45 | } 46 | } 47 | val result = accountManager.login(userName, password) 48 | if (result){ 49 | var intent = Intent(this, MainActivity::class.java) 50 | startActivity(intent) 51 | }else{ 52 | var toast = Toast.makeText(applicationContext, "Wrong login or password", Toast.LENGTH_LONG) 53 | toast.setGravity(Gravity.CENTER_VERTICAL, 0, 0) 54 | toast.show() 55 | } 56 | } 57 | } 58 | 59 | 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.activity 2 | 3 | import android.os.Bundle 4 | import com.google.android.material.snackbar.Snackbar 5 | import androidx.core.view.GravityCompat 6 | import androidx.appcompat.app.ActionBarDrawerToggle 7 | import android.view.MenuItem 8 | import androidx.drawerlayout.widget.DrawerLayout 9 | import com.google.android.material.navigation.NavigationView 10 | import androidx.appcompat.app.AppCompatActivity 11 | import androidx.appcompat.widget.Toolbar 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.RecyclerView 14 | import android.content.Intent 15 | import com.atiurin.espressoguide.adapters.ContactAdapter 16 | import com.atiurin.espressoguide.data.entities.Contact 17 | import com.atiurin.espressoguide.R 18 | import kotlin.collections.ArrayList 19 | import android.view.View 20 | import android.widget.Toast 21 | import com.atiurin.espressoguide.Logger 22 | import com.atiurin.espressoguide.async.ContactsPresenter 23 | import com.atiurin.espressoguide.async.ContactsProvider 24 | import com.atiurin.espressoguide.data.Tags 25 | import com.atiurin.espressoguide.idlingresources.idling 26 | import com.atiurin.espressoguide.managers.AccountManager 27 | import com.atiurin.espressoguide.view.CircleImageView 28 | 29 | 30 | class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, ContactsProvider { 31 | 32 | 33 | private lateinit var recyclerView: RecyclerView 34 | private lateinit var viewAdapter: ContactAdapter 35 | private lateinit var viewManager: RecyclerView.LayoutManager 36 | private lateinit var accountManager: AccountManager 37 | private val onItemClickListener: View.OnClickListener? = null 38 | private val contactsPresenter = ContactsPresenter(this) 39 | 40 | override fun onCreate(savedInstanceState: Bundle?) { 41 | super.onCreate(savedInstanceState) 42 | accountManager = AccountManager(applicationContext) 43 | if (!accountManager.isLoggedIn()) { 44 | val intent = Intent(applicationContext, LoginActivity::class.java) 45 | startActivity(intent) 46 | } 47 | setContentView(R.layout.activity_main) 48 | val toolbar: Toolbar = findViewById(R.id.toolbar) 49 | toolbar.setTitle(R.string.title_friends_list) 50 | setSupportActionBar(toolbar) 51 | val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout) 52 | val navView: NavigationView = findViewById(R.id.nav_view) 53 | val navigationAvatar = navView.getHeaderView(0).findViewById(R.id.navigation_user_avatar) 54 | navigationAvatar.setOnClickListener { 55 | startActivity(Intent(applicationContext, ProfileActivity::class.java)) 56 | } 57 | 58 | val toggle = ActionBarDrawerToggle( 59 | this, drawerLayout, toolbar, 60 | R.string.navigation_drawer_open, 61 | R.string.navigation_drawer_close 62 | ) 63 | drawerLayout.addDrawerListener(toggle) 64 | toggle.syncState() 65 | 66 | navView.setNavigationItemSelectedListener(this) 67 | 68 | viewManager = LinearLayoutManager(this) 69 | viewAdapter = ContactAdapter(ArrayList(), 70 | object : ContactAdapter.OnItemClickListener { 71 | override fun onItemClick(contact: Contact) { 72 | val intent = Intent(applicationContext, ChatActivity::class.java) 73 | intent.putExtra(INTENT_CONTACT_ID_EXTRA_NAME, contact.id) 74 | startActivity(intent) 75 | } 76 | override fun onItemLongClick(contact: Contact) { 77 | } 78 | }) 79 | recyclerView = findViewById(R.id.recycler_friends).apply { 80 | setHasFixedSize(true) 81 | layoutManager = viewManager 82 | adapter = viewAdapter 83 | } 84 | 85 | recyclerView.tag = Tags.CONTACTS_LIST 86 | contactsPresenter.getAllContacts() 87 | } 88 | 89 | override fun onBackPressed() { 90 | val drawerLayout: DrawerLayout = findViewById(com.atiurin.espressoguide.R.id.drawer_layout) 91 | if (drawerLayout.isDrawerOpen(GravityCompat.START)) { 92 | drawerLayout.closeDrawer(GravityCompat.START) 93 | } else { 94 | super.onBackPressed() 95 | } 96 | } 97 | 98 | override fun onResume() { 99 | super.onResume() 100 | contactsPresenter.getAllContacts() 101 | } 102 | 103 | override fun onContactsLoaded(contacts: List) { 104 | viewAdapter.updateData(contacts) 105 | viewAdapter.notifyDataSetChanged() 106 | idling { contactsIdling.onDataLoaded() } 107 | } 108 | 109 | override fun onFailedToLoadContacts(message: String?) { 110 | Toast.makeText(this, message, Toast.LENGTH_LONG).show() 111 | } 112 | 113 | override fun onNavigationItemSelected(item: MenuItem): Boolean { 114 | // Handle navigation view item clicks here. 115 | when (item.itemId) { 116 | R.id.nav_settings -> { 117 | Logger.error("Starting activity SettingsActivity") 118 | startActivity(Intent(applicationContext, SettingsActivity::class.java)) 119 | } 120 | R.id.nav_saved_messages -> { 121 | Snackbar.make(recyclerView, "Saved messages not implemented", Snackbar.LENGTH_LONG) 122 | .setAction("Action", null).show() 123 | } 124 | R.id.nav_profile -> { 125 | startActivity(Intent(applicationContext, ProfileActivity::class.java)) 126 | } 127 | R.id.nav_logout -> { 128 | accountManager.logout() 129 | val intent = Intent(applicationContext, LoginActivity::class.java) 130 | startActivity(intent) 131 | } 132 | } 133 | val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout) 134 | drawerLayout.closeDrawer(GravityCompat.START) 135 | return true 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/activity/ProfileActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.activity 2 | 3 | import android.os.Bundle 4 | import android.os.PersistableBundle 5 | import android.text.Editable 6 | import android.widget.EditText 7 | import android.widget.TextView 8 | import androidx.appcompat.app.AppCompatActivity 9 | import com.atiurin.espressoguide.R 10 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 11 | import com.atiurin.espressoguide.view.CircleImageView 12 | import kotlinx.android.synthetic.main.activity_profile.* 13 | 14 | class ProfileActivity : AppCompatActivity(){ 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContentView(R.layout.activity_profile) 18 | val avatar = findViewById(R.id.avatar) 19 | avatar.setImageDrawable(getDrawable(CURRENT_USER.avatar)) 20 | val name = findViewById(R.id.et_username) 21 | name.hint = CURRENT_USER.name 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/activity/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.activity 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.appcompat.widget.Toolbar 6 | import com.atiurin.espressoguide.Logger 7 | import com.atiurin.espressoguide.R 8 | import com.atiurin.espressoguide.fragment.settings.BlacklistFragment 9 | import com.atiurin.espressoguide.fragment.settings.SettingsFragmentNavigator 10 | import com.atiurin.espressoguide.fragment.settings.SettingsFragment 11 | 12 | class SettingsActivity : AppCompatActivity() { 13 | companion object { 14 | const val INTENT_FRAGMENT_CLASS = "INTENT_FRAGMENT_CLASS" 15 | } 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | Logger.error("SettingsActivity") 19 | super.onCreate(savedInstanceState) 20 | setContentView(R.layout.activity_settings) 21 | val intent = intent 22 | val fragmentClass = intent.getStringExtra(INTENT_FRAGMENT_CLASS) 23 | ?: SettingsFragmentNavigator.FragmentType.SETTINGS_LIST.name 24 | //TOOLBAR 25 | val toolbar: Toolbar = findViewById(R.id.toolbar) 26 | setSupportActionBar(toolbar) 27 | supportActionBar!!.setDisplayHomeAsUpEnabled(true) 28 | window.statusBarColor = getColor(R.color.colorPrimaryDark) 29 | SettingsFragmentNavigator.init(this) 30 | val fragmentManager = supportFragmentManager 31 | val fragment = SettingsFragmentNavigator.getFragment(fragmentClass) 32 | fragmentManager.beginTransaction() 33 | .add(R.id.fragment_container, fragment) 34 | .commit() 35 | } 36 | 37 | override fun onSupportNavigateUp(): Boolean { 38 | onBackPressed() 39 | return true 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/activity/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.activity 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import com.atiurin.espressoguide.managers.AccountManager 7 | 8 | class SplashActivity : AppCompatActivity() { 9 | 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | 13 | val accountManager = AccountManager(applicationContext) 14 | if (accountManager.isLoggedIn()){ 15 | val intent = Intent(applicationContext, MainActivity::class.java) 16 | startActivity(intent) 17 | }else{ 18 | val intent = Intent(applicationContext, LoginActivity::class.java) 19 | startActivity(intent) 20 | } 21 | finish() 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/adapters/ContactAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.adapters 2 | 3 | import android.view.ContextMenu 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.LinearLayout 8 | import android.widget.TextView 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.atiurin.espressoguide.R 11 | import com.atiurin.espressoguide.data.entities.Contact 12 | import com.atiurin.espressoguide.view.CircleImageView 13 | 14 | class ContactAdapter(private var contacts: List, val listener: OnItemClickListener) : 15 | RecyclerView.Adapter() { 16 | private val contactPosition = mutableMapOf() 17 | interface OnItemClickListener { 18 | fun onItemClick(contact: Contact) 19 | fun onItemLongClick(contact: Contact) 20 | } 21 | 22 | class MyViewHolder(val view: LinearLayout) : RecyclerView.ViewHolder(view) 23 | 24 | open fun updateData(data: List) { 25 | contacts = data 26 | } 27 | 28 | override fun onCreateViewHolder( 29 | parent: ViewGroup, 30 | viewType: Int 31 | ): MyViewHolder { 32 | val view = LayoutInflater.from(parent.context) 33 | .inflate(R.layout.friends_list_item, parent, false) as LinearLayout 34 | val viewHolder = MyViewHolder(view) 35 | return viewHolder 36 | } 37 | 38 | override fun onBindViewHolder(holder: MyViewHolder, position: Int) { 39 | holder.view.setOnClickListener { listener.onItemClick(contacts[position]) } 40 | holder.view.setOnLongClickListener { 41 | listener.onItemLongClick(contacts[position]) 42 | true 43 | } 44 | val tvTitle = holder.view.findViewById(R.id.tv_name) as TextView 45 | val avatar = holder.view.findViewById(R.id.avatar) as CircleImageView 46 | val status = holder.view.findViewById(R.id.tv_status) as TextView 47 | tvTitle.text = contacts[position].name 48 | status.text = contacts[position].status 49 | avatar.setImageDrawable(holder.view.context.resources.getDrawable(contacts[position].avatar)) 50 | contactPosition[contacts[position]] = holder.view 51 | } 52 | 53 | fun getContactView(contact: Contact): View { 54 | return contactPosition[contact]!! 55 | } 56 | override fun getItemCount() = contacts.size 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/adapters/MessageAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.adapters 2 | 3 | import android.view.Gravity 4 | import android.view.LayoutInflater 5 | import android.view.ViewGroup 6 | import android.widget.LinearLayout 7 | import android.widget.TextView 8 | import androidx.cardview.widget.CardView 9 | import androidx.core.view.get 10 | import androidx.recyclerview.widget.RecyclerView 11 | import com.atiurin.espressoguide.R 12 | import com.atiurin.espressoguide.data.entities.Message 13 | import com.atiurin.espressoguide.data.repositories.CURRENT_USER 14 | 15 | 16 | class MessageAdapter(private var messages: MutableList, val listener: OnItemClickListener) : 17 | RecyclerView.Adapter() { 18 | 19 | interface OnItemClickListener { 20 | fun onItemClick(item: Message) 21 | fun onItemLongClick(item: Message) 22 | } 23 | class MessageViewHolder(val view: LinearLayout) : RecyclerView.ViewHolder(view) 24 | 25 | open fun updateData(data: MutableList) { 26 | messages = data 27 | } 28 | 29 | override fun onCreateViewHolder( 30 | parent: ViewGroup, 31 | viewType: Int 32 | ): MessageViewHolder { 33 | val view = LayoutInflater.from(parent.context) 34 | .inflate(R.layout.message_item, parent, false) as LinearLayout 35 | return MessageViewHolder(view) 36 | } 37 | 38 | override fun onBindViewHolder(holder: MessageViewHolder, position: Int) { 39 | holder.view.setOnClickListener { 40 | listener.onItemClick(messages[position]) 41 | } 42 | holder.view.setOnLongClickListener { 43 | listener.onItemLongClick(messages[position]) 44 | true 45 | } 46 | val messageText = holder.view.findViewById(R.id.message_text) as TextView 47 | val authorName = holder.view.findViewById(R.id.author) as TextView 48 | val message = messages[position] 49 | messageText.text = message.text 50 | if (message.authorId == CURRENT_USER.id){ 51 | val view = holder.view[0] 52 | val cardView = view.findViewById(R.id.card_view) 53 | cardView.setCardBackgroundColor(view.context.resources.getColor(R.color.colorLight)) 54 | val layoutParams = view.layoutParams 55 | if (layoutParams is LinearLayout.LayoutParams){ 56 | layoutParams.gravity = Gravity.RIGHT 57 | } 58 | view.layoutParams = layoutParams 59 | } 60 | 61 | } 62 | 63 | override fun getItemCount() = messages.size 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/adapters/SettingsAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.adapters 2 | 3 | 4 | import android.view.LayoutInflater 5 | import android.view.ViewGroup 6 | import android.widget.LinearLayout 7 | import android.widget.TextView 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.atiurin.espressoguide.R 10 | import com.atiurin.espressoguide.core.settings.SettingsItem 11 | 12 | class SettingsAdapter(private var dataset: List, val listener: OnItemClickListener) : 13 | RecyclerView.Adapter() { 14 | 15 | interface OnItemClickListener { 16 | fun onItemClick(item: SettingsItem) 17 | } 18 | class MyViewHolder(val view: LinearLayout) : RecyclerView.ViewHolder(view) 19 | 20 | override fun onCreateViewHolder( 21 | parent: ViewGroup, 22 | viewType: Int 23 | ): MyViewHolder { 24 | val view = LayoutInflater.from(parent.context) 25 | .inflate(R.layout.settings_list_item, parent, false) as LinearLayout 26 | return MyViewHolder(view) 27 | } 28 | 29 | override fun onBindViewHolder(holder: MyViewHolder, position: Int) { 30 | holder.view.setOnClickListener { listener.onItemClick(dataset.get(position)) } 31 | val tvTitle = holder.view.findViewById(R.id.tv_name) as TextView 32 | tvTitle.text = dataset[position].name 33 | } 34 | 35 | override fun getItemCount() = dataset.size 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/async/ContactsPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.async 2 | 3 | import com.atiurin.espressoguide.data.entities.Contact 4 | import com.atiurin.espressoguide.idlingresources.idling 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.Job 8 | import kotlinx.coroutines.launch 9 | import kotlin.coroutines.CoroutineContext 10 | 11 | class ContactsPresenter ( 12 | private val executor: T, 13 | private val coroutineContext: CoroutineContext = Dispatchers.Default) { 14 | 15 | protected lateinit var scope: PresenterCoroutineScope 16 | 17 | fun getAllContacts() { 18 | idling { contactsIdling.onLoadStarted() } 19 | scope = PresenterCoroutineScope(coroutineContext) 20 | scope.launch { 21 | GetContacts()( 22 | UseCase.None, 23 | onSuccess = { executor.onContactsLoaded(it) }, 24 | onFailure = { executor.onFailedToLoadContacts(it.message) } 25 | ) 26 | } 27 | } 28 | 29 | fun getBlacklist() { 30 | idling { contactsIdling.onLoadStarted() } 31 | scope = PresenterCoroutineScope(coroutineContext) 32 | scope.launch { 33 | GetBlacklist()( 34 | UseCase.None, 35 | onSuccess = { executor.onContactsLoaded(it) }, 36 | onFailure = { executor.onFailedToLoadContacts(it.message) } 37 | ) 38 | } 39 | } 40 | } 41 | 42 | class PresenterCoroutineScope(context: CoroutineContext) : CoroutineScope { 43 | override val coroutineContext: CoroutineContext = context + Job() 44 | } 45 | 46 | interface ContactsProvider { 47 | fun onContactsLoaded(contacts: List) 48 | fun onFailedToLoadContacts(message: String?) 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/async/Either.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.async 2 | 3 | /** 4 | * An algebraic data type to provide either a [Failure][F] or a [Success][S] result. 5 | */ 6 | sealed class Either { 7 | 8 | /** 9 | * Calls [failed] with the [failure][Failure.failure] value if result is a [Failure] 10 | * or [succeeded] with the [success][Success.success] value if result is a [Success] 11 | */ 12 | inline fun fold(failed: (F) -> T, succeeded: (S) -> T): T = 13 | when (this) { 14 | is Failure -> failed(failure) 15 | is Success -> succeeded(success) 16 | } 17 | } 18 | 19 | data class Failure(val failure: F) : Either() 20 | 21 | data class Success(val success: S) : Either() 22 | 23 | /** 24 | * Allows chaining of multiple calls taking as argument the [success][Success.success] value of the previous call and 25 | * returning an [Either]. 26 | * 27 | * 1. Unwrap the result of the first call from the [Either] wrapper. 28 | * 2. Check if it is a [Success]. 29 | * 3. If yes, call the next function (passed as [ifSucceeded]) with the value of the [success][Success.success] 30 | * property as an input parameter (chain the calls). 31 | * 4. If no, just pass the [Failure] through as the end result of the whole call chain. 32 | * 33 | * In case any of the calls in the chain returns a [Failure], none of the subsequent flatmapped functions is called 34 | * and the whole chain returns this failure. 35 | * 36 | * @param ifSucceeded next function which should be called if this is a [Success]. The [success][Success.success] 37 | * value will be then passed as the input parameter. 38 | */ 39 | inline fun Either.flatMap(succeeded: (S1) -> Either): Either = 40 | fold({ this as Failure }, succeeded) 41 | 42 | /** 43 | * Map the [Success] value of the [Either] to another value. 44 | * 45 | * You can for example map an `Success` to an `Success` by 46 | * using the following code: 47 | * ``` 48 | * val fiveString: Either = Success("5") 49 | * val fiveInt : Either = fiveString.map { it.toInt() } 50 | * ``` 51 | */ 52 | inline fun Either.map(f: (S1) -> S2): Either = 53 | flatMap { Success(f(it)) } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/async/GetBlacklist.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.async 2 | 3 | import com.atiurin.espressoguide.data.entities.Contact 4 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 5 | import kotlinx.coroutines.delay 6 | 7 | class GetBlacklist: UseCase, UseCase.None>() { 8 | override suspend fun run(params: None): Either> { 9 | return try { 10 | delay(200) 11 | val contacts = ContactsRepositoty.getBlacklist() 12 | Success(contacts) 13 | } catch (e: Exception) { 14 | Failure(e) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/async/GetContacts.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.async 2 | 3 | import com.atiurin.espressoguide.data.entities.Contact 4 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 5 | import kotlinx.coroutines.delay 6 | 7 | class GetContacts : UseCase, UseCase.None>() { 8 | override suspend fun run(params: None): Either> { 9 | return try { 10 | delay(200) 11 | val contacts = ContactsRepositoty.getWhiteList() 12 | Success(contacts) 13 | } catch (e: Exception) { 14 | Failure(e) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/async/UseCase.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.async 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.coroutineScope 5 | import kotlinx.coroutines.launch 6 | 7 | 8 | /** 9 | * Base class for a `coroutine` use case. 10 | */ 11 | abstract class UseCase where Type : Any { 12 | 13 | /** 14 | * Runs the actual logic of the use case. 15 | */ 16 | abstract suspend fun run(params: Params): Either 17 | 18 | suspend operator fun invoke(params: Params, onSuccess: (Type) -> Unit, onFailure: (Exception) -> Unit) { 19 | val result = run(params) 20 | coroutineScope { 21 | launch(Dispatchers.Main) { 22 | result.fold( 23 | failed = { onFailure(it) }, 24 | succeeded = { onSuccess(it) } 25 | ) 26 | } 27 | } 28 | } 29 | 30 | /** 31 | * Placeholder for a use case that doesn't need any input parameters. 32 | */ 33 | object None 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/core/settings/SettingsItem.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.core.settings 2 | 3 | data class SettingsItem(val type: SettingsType, val name: String) 4 | 5 | enum class SettingsType{ 6 | ACCOUNT, 7 | PRIVACY, 8 | NOTIFICATIONS, 9 | BLACKLIST 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/Tags.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data 2 | 3 | enum class Tags{ 4 | CONTACTS_LIST, 5 | MESSAGES_LIST 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/entities/Contact.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.entities 2 | 3 | data class Contact( val id: Int,val name: String, val status: String, val avatar: Int) -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/entities/Message.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.entities 2 | 3 | data class Message(val authorId: Int, 4 | val receiverId: Int, 5 | val text: String) -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/entities/User.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.entities 2 | 3 | data class User( val id: Int,val name: String, val avatar: Int, val login: String, val password: String) -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/loaders/MessageLoader.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.loaders 2 | 3 | import com.atiurin.espressoguide.data.entities.Message 4 | import com.atiurin.espressoguide.data.repositories.MESSAGES 5 | 6 | open class MessageLoader{ 7 | open fun load() : HashMap>{ 8 | return MESSAGES 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/repositories/ContactsRepositoty.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.repositories 2 | 3 | import android.os.Handler 4 | import com.atiurin.espressoguide.adapters.ContactAdapter 5 | import com.atiurin.espressoguide.data.entities.Contact 6 | 7 | object ContactsRepositoty { 8 | private val contacts = CONTACTS 9 | private val blacklist = mutableListOf() 10 | fun getContact(id: Int): Contact { 11 | return contacts.find { it.id == id }!! 12 | } 13 | 14 | fun getContact(name: String): Contact { 15 | return contacts.find { it.name == name }!! 16 | } 17 | 18 | fun getAllContacts(): ArrayList { 19 | return contacts 20 | } 21 | 22 | fun getWhiteList(): List{ 23 | return contacts - blacklist 24 | } 25 | 26 | fun addToBlacklist(contactId: Int) { 27 | blacklist.add(getContact(contactId)) 28 | } 29 | 30 | fun deleteFromBlacklist(contactId: Int) { 31 | blacklist.remove(getContact(contactId)) 32 | } 33 | 34 | fun getBlacklist(): MutableList { 35 | return blacklist 36 | } 37 | 38 | fun clearBlacklist(){ 39 | blacklist.clear() 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/repositories/MessageRepository.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.repositories 2 | 3 | import com.atiurin.espressoguide.data.entities.Message 4 | import com.atiurin.espressoguide.data.loaders.MessageLoader 5 | 6 | 7 | object MessageRepository { 8 | var messages : HashMap> 9 | 10 | init { 11 | messages = loadMessages(MessageLoader()) 12 | } 13 | 14 | fun loadMessages(loader: MessageLoader) 15 | : HashMap>{ 16 | messages = loader.load() 17 | return messages 18 | } 19 | 20 | fun searchMessage(contactId: Int, author: Int, recipient: Int, text: String) : Message?{ 21 | return messages[contactId]?.find { it.authorId == author && it.receiverId == recipient && it.text == text } 22 | } 23 | 24 | fun getChatMessages(contactId: Int): MutableList{ 25 | val chatMessages = messages[contactId] 26 | if (chatMessages== null){ 27 | messages[contactId] = mutableListOf() 28 | } 29 | return messages[contactId] as MutableList 30 | } 31 | 32 | fun clearChatMessages(contactId: Int){ 33 | getChatMessages(contactId).clear() 34 | } 35 | 36 | fun addChatMessage(contactId: Int, message: Message){ 37 | getChatMessages(contactId).add(message) 38 | } 39 | 40 | fun getChatMessagesCount(contactId: Int) : Int{ 41 | return getChatMessages(contactId).size 42 | } 43 | 44 | 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/data/repositories/Storage.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.data.repositories 2 | 3 | import com.atiurin.espressoguide.R 4 | import com.atiurin.espressoguide.data.entities.Contact 5 | import com.atiurin.espressoguide.data.entities.Message 6 | import com.atiurin.espressoguide.data.entities.User 7 | 8 | val CURRENT_USER = User(1, "Joey Tribbiani", Avatars.JOEY.drawable, "joey", "1234") 9 | 10 | val CONTACTS = arrayListOf( 11 | Contact(2, "Chandler Bing", "Joey doesn't share food!", Avatars.CHANDLER.drawable), 12 | Contact(3, "Ross Geller", "UNAGI", Avatars.ROSS.drawable), 13 | Contact(4, "Rachel Green", "I got off the plane!", Avatars.RACHEL.drawable), 14 | Contact(5, "Phoebe Buffay", "Smelly cat, smelly cat..", Avatars.PHOEBE.drawable), 15 | Contact(6, "Monica Geller", "I need to clean up", Avatars.MONICA.drawable), 16 | Contact(7, "Gunther", "They were on break :(", Avatars.GUNTHER.drawable), 17 | Contact(8, "Janice", "Oh. My. God", Avatars.JANICE.drawable), 18 | Contact(9, "Bob", "I wanna drink", Avatars.DEFAULT.drawable), 19 | Contact(10, "Marty McFly", "Back to the ...", Avatars.DEFAULT.drawable), 20 | Contact(12, "Emmet Brown", "Time fluid capacitor", Avatars.DEFAULT.drawable), 21 | Contact(13, "Friend_1", "I'm a friend", Avatars.DEFAULT.drawable), 22 | Contact(14, "Friend_2", "I'm a friend", Avatars.DEFAULT.drawable), 23 | Contact(15, "Friend_3", "I'm a friend", Avatars.DEFAULT.drawable), 24 | 25 | Contact(16, "Friend_4", "I'm a friend", Avatars.DEFAULT.drawable), 26 | Contact(17, "Friend_5", "I'm a friend", Avatars.DEFAULT.drawable) 27 | ) 28 | 29 | enum class Avatars(val drawable: Int) { 30 | CHANDLER(R.drawable.chandler), 31 | ROSS(R.drawable.ross), 32 | MONICA(R.drawable.monica), 33 | RACHEL(R.drawable.rachel), 34 | PHOEBE(R.drawable.phoebe), 35 | GUNTHER(R.drawable.gunther), 36 | JOEY(R.drawable.joey), 37 | JANICE(R.drawable.janice), 38 | DEFAULT(R.drawable.default_avatar) 39 | } 40 | 41 | 42 | val MESSAGES = hashMapOf>( 43 | 2 to arrayListOf( 44 | Message(1, 2, "What's up Chandler"), 45 | Message(2, 1, "Hi Joey"), 46 | Message(1, 2, "Let's drink coffee"), 47 | Message(2, 1, "Ok") 48 | ), 49 | 4 to arrayListOf( 50 | Message(1, 3, "How u doing?"), 51 | Message(3, 1, "Better"), 52 | Message(1, 3, "Come with me"), 53 | ), 54 | 3 to arrayListOf( 55 | Message(1, 3, "Do u wanna coffee?"), 56 | Message(3, 1, "yep, let's go") 57 | ), 58 | 8 to arrayListOf( 59 | Message(8, 1, "We are neighbours!"), 60 | Message(1, 8, "oh my God") 61 | ) 62 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/fragment/settings/BlacklistFragment.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.fragment.settings 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | import android.view.* 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.appcompat.widget.PopupMenu 8 | import androidx.appcompat.widget.Toolbar 9 | import androidx.fragment.app.Fragment 10 | import androidx.recyclerview.widget.LinearLayoutManager 11 | import androidx.recyclerview.widget.RecyclerView 12 | import com.atiurin.espressoguide.Logger 13 | import com.atiurin.espressoguide.R 14 | import com.atiurin.espressoguide.adapters.ContactAdapter 15 | import com.atiurin.espressoguide.async.ContactsPresenter 16 | import com.atiurin.espressoguide.async.ContactsProvider 17 | import com.atiurin.espressoguide.data.entities.Contact 18 | import com.atiurin.espressoguide.data.repositories.ContactsRepositoty 19 | import com.atiurin.espressoguide.idlingresources.idling 20 | 21 | class BlacklistFragment( 22 | val activity: Activity 23 | ) : Fragment(), ContactsProvider { 24 | 25 | private lateinit var recyclerView: RecyclerView 26 | private lateinit var viewAdapter: ContactAdapter 27 | private lateinit var viewManager: RecyclerView.LayoutManager 28 | private val contactsPresenter = ContactsPresenter(this) 29 | 30 | override fun onCreateView( 31 | inflater: LayoutInflater, 32 | container: ViewGroup?, 33 | savedInstanceState: Bundle? 34 | ): View? { 35 | val toolbar: Toolbar = activity.findViewById(R.id.toolbar) 36 | (activity as AppCompatActivity).setSupportActionBar(toolbar) 37 | toolbar.title = "Blacklist" 38 | 39 | FragmentInfoContainer.currentFragment = this.javaClass.name 40 | val rootView = inflater.inflate(R.layout.fragment_blacklist, container, false) 41 | viewManager = LinearLayoutManager(activity) 42 | viewAdapter = ContactAdapter( 43 | emptyList(), 44 | object : ContactAdapter.OnItemClickListener { 45 | override fun onItemClick(contact: Contact) { 46 | Logger.debug("Click on item ${contact.name}") 47 | 48 | } 49 | 50 | override fun onItemLongClick(contact: Contact) { 51 | Logger.debug("Long Click on item ${contact.name}") 52 | 53 | val pop = PopupMenu(activity, viewAdapter.getContactView(contact)) 54 | pop.menu.add("Delete ${contact.name} from blacklist") 55 | pop.setOnMenuItemClickListener { item -> 56 | Logger.debug("MenuClicked on item ${contact.name}") 57 | ContactsRepositoty.deleteFromBlacklist(contact.id) 58 | contactsPresenter.getBlacklist() 59 | true 60 | } 61 | pop.show() 62 | true 63 | } 64 | }) 65 | recyclerView = rootView.findViewById(R.id.recycler_blacklist).apply { 66 | layoutManager = viewManager 67 | adapter = viewAdapter 68 | } 69 | registerForContextMenu(recyclerView) 70 | contactsPresenter.getBlacklist() 71 | return rootView 72 | } 73 | 74 | override fun onContactsLoaded(contacts: List) { 75 | viewAdapter.updateData(contacts) 76 | viewAdapter.notifyDataSetChanged() 77 | idling { contactsIdling.onDataLoaded()} 78 | } 79 | 80 | override fun onFailedToLoadContacts(message: String?) { 81 | TODO("not implemented") //To change body of created functions use File | Settings | File Templates. 82 | } 83 | 84 | override fun onContextItemSelected(item: MenuItem): Boolean { 85 | return super.onContextItemSelected(item) 86 | 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/fragment/settings/FragmentInfoContainer.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.fragment.settings 2 | 3 | object FragmentInfoContainer { 4 | var currentFragment: String? = null 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/fragment/settings/SettingsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.fragment.settings 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.appcompat.widget.Toolbar 10 | import androidx.fragment.app.Fragment 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.atiurin.espressoguide.Logger 14 | import com.atiurin.espressoguide.R 15 | import com.atiurin.espressoguide.adapters.SettingsAdapter 16 | import com.atiurin.espressoguide.core.settings.SettingsItem 17 | import com.atiurin.espressoguide.core.settings.SettingsType 18 | import com.google.android.material.snackbar.Snackbar 19 | 20 | class SettingsFragment( 21 | val activity: Activity 22 | ) : Fragment() { 23 | private lateinit var recyclerView: RecyclerView 24 | private lateinit var viewAdapter: SettingsAdapter 25 | private lateinit var viewManager: RecyclerView.LayoutManager 26 | override fun onCreateView( 27 | inflater: LayoutInflater, 28 | container: ViewGroup?, 29 | savedInstanceState: Bundle? 30 | ): View? { 31 | FragmentInfoContainer.currentFragment = this.javaClass.name 32 | val rootView = inflater.inflate(R.layout.fragment_settings_list, container, false) 33 | val toolbar: Toolbar = activity.findViewById(R.id.toolbar) 34 | (activity as AppCompatActivity).setSupportActionBar(toolbar) 35 | toolbar.title = "Settings" 36 | viewManager = LinearLayoutManager(activity) 37 | viewAdapter = SettingsAdapter( 38 | listOf( 39 | SettingsItem(SettingsType.ACCOUNT, "Account"), 40 | SettingsItem(SettingsType.PRIVACY, "Privacy"), 41 | SettingsItem(SettingsType.NOTIFICATIONS, "Notifications"), 42 | SettingsItem(SettingsType.BLACKLIST, "Blacklist") 43 | ), 44 | object : SettingsAdapter.OnItemClickListener { 45 | override fun onItemClick(item: SettingsItem) { 46 | Logger.debug("Click on item ${item.name}") 47 | when (item.type) { 48 | SettingsType.BLACKLIST -> SettingsFragmentNavigator.go(BlacklistFragment::class.java) 49 | else -> { 50 | Snackbar.make(recyclerView, "This option is not implemented", Snackbar.LENGTH_LONG) 51 | .setAction("Action", null).show() 52 | } 53 | } 54 | } 55 | }) 56 | recyclerView = rootView.findViewById(R.id.settings_list).apply { 57 | layoutManager = viewManager 58 | adapter = viewAdapter 59 | } 60 | return rootView 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/fragment/settings/SettingsFragmentNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.fragment.settings 2 | 3 | 4 | import android.annotation.SuppressLint 5 | import android.app.Activity 6 | import android.content.Intent 7 | import androidx.fragment.app.Fragment 8 | import com.atiurin.espressoguide.activity.SettingsActivity 9 | 10 | @SuppressLint("StaticFieldLeak") 11 | object SettingsFragmentNavigator { 12 | lateinit var activity: Activity 13 | 14 | fun init(activity: Activity) { 15 | this.activity = activity 16 | } 17 | 18 | fun go(fragmentClass: Class<*>) { 19 | val intent = Intent(activity, SettingsActivity::class.java) 20 | intent.putExtra(SettingsActivity.INTENT_FRAGMENT_CLASS, getKey(fragmentClass)) 21 | activity.startActivity(intent) 22 | } 23 | 24 | fun getKey(fragmentClass: Class<*>): String { 25 | return when (fragmentClass) { 26 | BlacklistFragment::class.java -> FragmentType.BLACKLIST.name 27 | else -> FragmentType.SETTINGS_LIST.name 28 | } 29 | } 30 | 31 | fun getFragment(key: String): Fragment { 32 | return when(FragmentType.valueOf(key)){ 33 | FragmentType.BLACKLIST -> BlacklistFragment(activity) 34 | else -> SettingsFragment(activity) 35 | } 36 | } 37 | 38 | enum class FragmentType{ 39 | BLACKLIST, SETTINGS_LIST 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/idlingresources/AbstractIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.idlingresources 2 | 3 | import androidx.annotation.Nullable 4 | import java.util.concurrent.atomic.AtomicBoolean 5 | 6 | abstract class AbstractIdlingResource : AppIdlingResource { 7 | @Nullable 8 | @Volatile 9 | private var resourceCallback: AppIdlingResource.AppResourceCallback? = null 10 | private val idleNow = AtomicBoolean(true) 11 | override fun getName(): String { 12 | return this.javaClass.name 13 | } 14 | 15 | override fun isIdle(): Boolean { 16 | return idleNow.get() 17 | } 18 | 19 | override fun registerIdleTransitionCallback(resourceCallback: AppIdlingResource.AppResourceCallback) { 20 | this.resourceCallback = resourceCallback 21 | } 22 | 23 | private fun setIdleState(isIdleNow: Boolean) { 24 | idleNow.set(isIdleNow) 25 | if (isIdleNow && resourceCallback != null) { 26 | resourceCallback?.onTransitionToIdleState() 27 | } 28 | } 29 | 30 | fun onLoadStarted() = setIdleState(false) 31 | fun onDataLoaded() = setIdleState(true) 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/idlingresources/AppIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.idlingresources 2 | 3 | 4 | /* 5 | * This interface is a representation of Espresso.IdlingResource interface. 6 | * It is created to avoid espresso_idling dependency in app 7 | */ 8 | interface AppIdlingResource { 9 | fun getName(): String 10 | fun isIdle(): Boolean 11 | fun registerIdleTransitionCallback(resourceCallback: AppResourceCallback) 12 | 13 | interface AppResourceCallback{ 14 | fun onTransitionToIdleState() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/idlingresources/IdlingResourses.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.idlingresources 2 | 3 | class ContactsIdling : AbstractIdlingResource() 4 | class MessagesIdling : AbstractIdlingResource() 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/idlingresources/IdlingScope.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.idlingresources 2 | 3 | import java.util.concurrent.atomic.AtomicReference 4 | 5 | fun isReleaseBuild(): Boolean { 6 | return false 7 | } 8 | 9 | interface IdlingScope { 10 | val contactsIdling: ContactsIdling 11 | val messagesIdling: MessagesIdling 12 | } 13 | 14 | val idlingContainer = AtomicReference() 15 | 16 | fun idling(action: IdlingScope.() -> Unit){ 17 | if (!isReleaseBuild()){ 18 | idlingContainer.get()?.action() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/managers/AccountManager.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.managers 2 | 3 | import android.content.Context 4 | 5 | class AccountManager(val context: Context){ 6 | companion object { 7 | private const val expectedUserName = "joey" 8 | private const val expectedPassword = "1234" 9 | private const val USER_KEY = "username" 10 | private const val PASSWORD_KEY = "password" 11 | } 12 | 13 | fun login(user: String, password: String) : Boolean{ 14 | var success = false 15 | // there should be some network request to app server 16 | if ((user == expectedUserName) &&(password == expectedPassword)){ 17 | success = true 18 | with(PrefsManager(context)){ 19 | savePref(USER_KEY, user) 20 | savePref(PASSWORD_KEY, password) 21 | } 22 | } 23 | return success 24 | } 25 | 26 | fun isLoggedIn() : Boolean{ 27 | var userName = "" 28 | var password = "" 29 | with(PrefsManager(context)){ 30 | userName = getPref(USER_KEY) 31 | password = getPref(PASSWORD_KEY) 32 | } 33 | if (userName.isEmpty() || password.isEmpty()) return false 34 | return true 35 | } 36 | 37 | fun logout(){ 38 | with(PrefsManager(context)){ 39 | remove(USER_KEY) 40 | remove(PASSWORD_KEY) 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/managers/PrefsManager.kt: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.managers 2 | 3 | import android.content.Context.MODE_PRIVATE 4 | import android.R.id.edit 5 | import android.app.Application 6 | import android.content.Context 7 | import android.content.SharedPreferences 8 | import android.content.Context.MODE_PRIVATE 9 | 10 | 11 | 12 | class PrefsManager(val context: Context){ 13 | val PREFS_NAME = "MyPrefsFileName" 14 | 15 | fun savePref(key: String, value: String){ 16 | val editor = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit() 17 | editor.putString(key, value) 18 | editor.apply() 19 | } 20 | 21 | fun getPref(key: String) : String{ 22 | val prefs = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) 23 | var value = prefs.getString(key, null) 24 | if (value == null) value = "" 25 | return value 26 | } 27 | 28 | fun remove(key: String){ 29 | val editor = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit() 30 | editor.remove(key) 31 | editor.commit() 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/atiurin/espressoguide/view/CircleImageView.java: -------------------------------------------------------------------------------- 1 | package com.atiurin.espressoguide.view; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.content.Context; 5 | import android.content.res.TypedArray; 6 | import android.graphics.Bitmap; 7 | import android.graphics.BitmapShader; 8 | import android.graphics.Canvas; 9 | import android.graphics.Color; 10 | import android.graphics.ColorFilter; 11 | import android.graphics.Matrix; 12 | import android.graphics.Outline; 13 | import android.graphics.Paint; 14 | import android.graphics.Rect; 15 | import android.graphics.RectF; 16 | import android.graphics.Shader; 17 | import android.graphics.drawable.BitmapDrawable; 18 | import android.graphics.drawable.ColorDrawable; 19 | import android.graphics.drawable.Drawable; 20 | import android.net.Uri; 21 | import android.os.Build; 22 | import android.util.AttributeSet; 23 | import android.view.MotionEvent; 24 | import android.view.View; 25 | import android.view.ViewOutlineProvider; 26 | import android.widget.ImageView; 27 | import androidx.annotation.ColorInt; 28 | import androidx.annotation.ColorRes; 29 | import androidx.annotation.DrawableRes; 30 | import androidx.annotation.RequiresApi; 31 | import androidx.appcompat.widget.AppCompatImageView; 32 | import com.atiurin.espressoguide.R; 33 | 34 | @SuppressWarnings("UnusedDeclaration") 35 | public class CircleImageView extends AppCompatImageView { 36 | 37 | private static final ScaleType SCALE_TYPE = ScaleType.CENTER_CROP; 38 | 39 | private static final Bitmap.Config BITMAP_CONFIG = Bitmap.Config.ARGB_8888; 40 | private static final int COLORDRAWABLE_DIMENSION = 2; 41 | 42 | private static final int DEFAULT_BORDER_WIDTH = 0; 43 | private static final int DEFAULT_BORDER_COLOR = Color.BLACK; 44 | private static final int DEFAULT_CIRCLE_BACKGROUND_COLOR = Color.TRANSPARENT; 45 | private static final boolean DEFAULT_BORDER_OVERLAY = false; 46 | 47 | private final RectF mDrawableRect = new RectF(); 48 | private final RectF mBorderRect = new RectF(); 49 | 50 | private final Matrix mShaderMatrix = new Matrix(); 51 | private final Paint mBitmapPaint = new Paint(); 52 | private final Paint mBorderPaint = new Paint(); 53 | private final Paint mCircleBackgroundPaint = new Paint(); 54 | 55 | private int mBorderColor = DEFAULT_BORDER_COLOR; 56 | private int mBorderWidth = DEFAULT_BORDER_WIDTH; 57 | private int mCircleBackgroundColor = DEFAULT_CIRCLE_BACKGROUND_COLOR; 58 | 59 | private Bitmap mBitmap; 60 | private BitmapShader mBitmapShader; 61 | private int mBitmapWidth; 62 | private int mBitmapHeight; 63 | 64 | private float mDrawableRadius; 65 | private float mBorderRadius; 66 | 67 | private ColorFilter mColorFilter; 68 | 69 | private boolean mReady; 70 | private boolean mSetupPending; 71 | private boolean mBorderOverlay; 72 | private boolean mDisableCircularTransformation; 73 | 74 | public CircleImageView(Context context) { 75 | super(context); 76 | 77 | init(); 78 | } 79 | 80 | public CircleImageView(Context context, AttributeSet attrs) { 81 | this(context, attrs, 0); 82 | } 83 | 84 | public CircleImageView(Context context, AttributeSet attrs, int defStyle) { 85 | super(context, attrs, defStyle); 86 | 87 | TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView, defStyle, 0); 88 | 89 | mBorderWidth = a.getDimensionPixelSize(R.styleable.CircleImageView_civ_border_width, DEFAULT_BORDER_WIDTH); 90 | mBorderColor = a.getColor(R.styleable.CircleImageView_civ_border_color, DEFAULT_BORDER_COLOR); 91 | mBorderOverlay = a.getBoolean(R.styleable.CircleImageView_civ_border_overlay, DEFAULT_BORDER_OVERLAY); 92 | mCircleBackgroundColor = a.getColor(R.styleable.CircleImageView_civ_circle_background_color, DEFAULT_CIRCLE_BACKGROUND_COLOR); 93 | 94 | a.recycle(); 95 | 96 | init(); 97 | } 98 | 99 | private void init() { 100 | super.setScaleType(SCALE_TYPE); 101 | mReady = true; 102 | 103 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 104 | setOutlineProvider(new OutlineProvider()); 105 | } 106 | 107 | if (mSetupPending) { 108 | setup(); 109 | mSetupPending = false; 110 | } 111 | } 112 | 113 | @Override 114 | public ScaleType getScaleType() { 115 | return SCALE_TYPE; 116 | } 117 | 118 | @Override 119 | public void setScaleType(ScaleType scaleType) { 120 | if (scaleType != SCALE_TYPE) { 121 | throw new IllegalArgumentException(String.format("ScaleType %s not supported.", scaleType)); 122 | } 123 | } 124 | 125 | @Override 126 | public void setAdjustViewBounds(boolean adjustViewBounds) { 127 | if (adjustViewBounds) { 128 | throw new IllegalArgumentException("adjustViewBounds not supported."); 129 | } 130 | } 131 | 132 | @Override 133 | protected void onDraw(Canvas canvas) { 134 | if (mDisableCircularTransformation) { 135 | super.onDraw(canvas); 136 | return; 137 | } 138 | 139 | if (mBitmap == null) { 140 | return; 141 | } 142 | 143 | if (mCircleBackgroundColor != Color.TRANSPARENT) { 144 | canvas.drawCircle(mDrawableRect.centerX(), mDrawableRect.centerY(), mDrawableRadius, mCircleBackgroundPaint); 145 | } 146 | canvas.drawCircle(mDrawableRect.centerX(), mDrawableRect.centerY(), mDrawableRadius, mBitmapPaint); 147 | if (mBorderWidth > 0) { 148 | canvas.drawCircle(mBorderRect.centerX(), mBorderRect.centerY(), mBorderRadius, mBorderPaint); 149 | } 150 | } 151 | 152 | @Override 153 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { 154 | super.onSizeChanged(w, h, oldw, oldh); 155 | setup(); 156 | } 157 | 158 | @Override 159 | public void setPadding(int left, int top, int right, int bottom) { 160 | super.setPadding(left, top, right, bottom); 161 | setup(); 162 | } 163 | 164 | @Override 165 | public void setPaddingRelative(int start, int top, int end, int bottom) { 166 | super.setPaddingRelative(start, top, end, bottom); 167 | setup(); 168 | } 169 | 170 | public int getBorderColor() { 171 | return mBorderColor; 172 | } 173 | 174 | public void setBorderColor(@ColorInt int borderColor) { 175 | if (borderColor == mBorderColor) { 176 | return; 177 | } 178 | 179 | mBorderColor = borderColor; 180 | mBorderPaint.setColor(mBorderColor); 181 | invalidate(); 182 | } 183 | 184 | public int getCircleBackgroundColor() { 185 | return mCircleBackgroundColor; 186 | } 187 | 188 | public void setCircleBackgroundColor(@ColorInt int circleBackgroundColor) { 189 | if (circleBackgroundColor == mCircleBackgroundColor) { 190 | return; 191 | } 192 | 193 | mCircleBackgroundColor = circleBackgroundColor; 194 | mCircleBackgroundPaint.setColor(circleBackgroundColor); 195 | invalidate(); 196 | } 197 | 198 | public void setCircleBackgroundColorResource(@ColorRes int circleBackgroundRes) { 199 | setCircleBackgroundColor(getContext().getResources().getColor(circleBackgroundRes)); 200 | } 201 | 202 | public int getBorderWidth() { 203 | return mBorderWidth; 204 | } 205 | 206 | public void setBorderWidth(int borderWidth) { 207 | if (borderWidth == mBorderWidth) { 208 | return; 209 | } 210 | 211 | mBorderWidth = borderWidth; 212 | setup(); 213 | } 214 | 215 | public boolean isBorderOverlay() { 216 | return mBorderOverlay; 217 | } 218 | 219 | public void setBorderOverlay(boolean borderOverlay) { 220 | if (borderOverlay == mBorderOverlay) { 221 | return; 222 | } 223 | 224 | mBorderOverlay = borderOverlay; 225 | setup(); 226 | } 227 | 228 | public boolean isDisableCircularTransformation() { 229 | return mDisableCircularTransformation; 230 | } 231 | 232 | public void setDisableCircularTransformation(boolean disableCircularTransformation) { 233 | if (mDisableCircularTransformation == disableCircularTransformation) { 234 | return; 235 | } 236 | 237 | mDisableCircularTransformation = disableCircularTransformation; 238 | initializeBitmap(); 239 | } 240 | 241 | @Override 242 | public void setImageBitmap(Bitmap bm) { 243 | super.setImageBitmap(bm); 244 | initializeBitmap(); 245 | } 246 | 247 | @Override 248 | public void setImageDrawable(Drawable drawable) { 249 | super.setImageDrawable(drawable); 250 | initializeBitmap(); 251 | } 252 | 253 | @Override 254 | public void setImageResource(@DrawableRes int resId) { 255 | super.setImageResource(resId); 256 | initializeBitmap(); 257 | } 258 | 259 | @Override 260 | public void setImageURI(Uri uri) { 261 | super.setImageURI(uri); 262 | initializeBitmap(); 263 | } 264 | 265 | @Override 266 | public void setColorFilter(ColorFilter cf) { 267 | if (cf == mColorFilter) { 268 | return; 269 | } 270 | 271 | mColorFilter = cf; 272 | applyColorFilter(); 273 | invalidate(); 274 | } 275 | 276 | @Override 277 | public ColorFilter getColorFilter() { 278 | return mColorFilter; 279 | } 280 | 281 | private void applyColorFilter() { 282 | mBitmapPaint.setColorFilter(mColorFilter); 283 | } 284 | 285 | private Bitmap getBitmapFromDrawable(Drawable drawable) { 286 | if (drawable == null) { 287 | return null; 288 | } 289 | 290 | if (drawable instanceof BitmapDrawable) { 291 | return ((BitmapDrawable) drawable).getBitmap(); 292 | } 293 | 294 | try { 295 | Bitmap bitmap; 296 | 297 | if (drawable instanceof ColorDrawable) { 298 | bitmap = Bitmap.createBitmap(COLORDRAWABLE_DIMENSION, COLORDRAWABLE_DIMENSION, BITMAP_CONFIG); 299 | } else { 300 | bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), BITMAP_CONFIG); 301 | } 302 | 303 | Canvas canvas = new Canvas(bitmap); 304 | drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); 305 | drawable.draw(canvas); 306 | return bitmap; 307 | } catch (Exception e) { 308 | e.printStackTrace(); 309 | return null; 310 | } 311 | } 312 | 313 | private void initializeBitmap() { 314 | if (mDisableCircularTransformation) { 315 | mBitmap = null; 316 | } else { 317 | mBitmap = getBitmapFromDrawable(getDrawable()); 318 | } 319 | setup(); 320 | } 321 | 322 | private void setup() { 323 | if (!mReady) { 324 | mSetupPending = true; 325 | return; 326 | } 327 | 328 | if (getWidth() == 0 && getHeight() == 0) { 329 | return; 330 | } 331 | 332 | if (mBitmap == null) { 333 | invalidate(); 334 | return; 335 | } 336 | 337 | mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); 338 | 339 | mBitmapPaint.setAntiAlias(true); 340 | mBitmapPaint.setShader(mBitmapShader); 341 | 342 | mBorderPaint.setStyle(Paint.Style.STROKE); 343 | mBorderPaint.setAntiAlias(true); 344 | mBorderPaint.setColor(mBorderColor); 345 | mBorderPaint.setStrokeWidth(mBorderWidth); 346 | 347 | mCircleBackgroundPaint.setStyle(Paint.Style.FILL); 348 | mCircleBackgroundPaint.setAntiAlias(true); 349 | mCircleBackgroundPaint.setColor(mCircleBackgroundColor); 350 | 351 | mBitmapHeight = mBitmap.getHeight(); 352 | mBitmapWidth = mBitmap.getWidth(); 353 | 354 | mBorderRect.set(calculateBounds()); 355 | mBorderRadius = Math.min((mBorderRect.height() - mBorderWidth) / 2.0f, (mBorderRect.width() - mBorderWidth) / 2.0f); 356 | 357 | mDrawableRect.set(mBorderRect); 358 | if (!mBorderOverlay && mBorderWidth > 0) { 359 | mDrawableRect.inset(mBorderWidth - 1.0f, mBorderWidth - 1.0f); 360 | } 361 | mDrawableRadius = Math.min(mDrawableRect.height() / 2.0f, mDrawableRect.width() / 2.0f); 362 | 363 | applyColorFilter(); 364 | updateShaderMatrix(); 365 | invalidate(); 366 | } 367 | 368 | private RectF calculateBounds() { 369 | int availableWidth = getWidth() - getPaddingLeft() - getPaddingRight(); 370 | int availableHeight = getHeight() - getPaddingTop() - getPaddingBottom(); 371 | 372 | int sideLength = Math.min(availableWidth, availableHeight); 373 | 374 | float left = getPaddingLeft() + (availableWidth - sideLength) / 2f; 375 | float top = getPaddingTop() + (availableHeight - sideLength) / 2f; 376 | 377 | return new RectF(left, top, left + sideLength, top + sideLength); 378 | } 379 | 380 | private void updateShaderMatrix() { 381 | float scale; 382 | float dx = 0; 383 | float dy = 0; 384 | 385 | mShaderMatrix.set(null); 386 | 387 | if (mBitmapWidth * mDrawableRect.height() > mDrawableRect.width() * mBitmapHeight) { 388 | scale = mDrawableRect.height() / (float) mBitmapHeight; 389 | dx = (mDrawableRect.width() - mBitmapWidth * scale) * 0.5f; 390 | } else { 391 | scale = mDrawableRect.width() / (float) mBitmapWidth; 392 | dy = (mDrawableRect.height() - mBitmapHeight * scale) * 0.5f; 393 | } 394 | 395 | mShaderMatrix.setScale(scale, scale); 396 | mShaderMatrix.postTranslate((int) (dx + 0.5f) + mDrawableRect.left, (int) (dy + 0.5f) + mDrawableRect.top); 397 | 398 | mBitmapShader.setLocalMatrix(mShaderMatrix); 399 | } 400 | 401 | @SuppressLint("ClickableViewAccessibility") 402 | @Override 403 | public boolean onTouchEvent(MotionEvent event) { 404 | return inTouchableArea(event.getX(), event.getY()) && super.onTouchEvent(event); 405 | } 406 | 407 | private boolean inTouchableArea(float x, float y) { 408 | return Math.pow(x - mBorderRect.centerX(), 2) + Math.pow(y - mBorderRect.centerY(), 2) <= Math.pow(mBorderRadius, 2); 409 | } 410 | 411 | @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) 412 | private class OutlineProvider extends ViewOutlineProvider { 413 | 414 | @Override 415 | public void getOutline(View view, Outline outline) { 416 | Rect bounds = new Rect(); 417 | mBorderRect.roundOut(bounds); 418 | outline.setRoundRect(bounds, bounds.width() / 2.0f); 419 | } 420 | 421 | } 422 | 423 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_account.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_attach_file.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_exit.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_messages.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_send.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-hdpi/ic_account.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_attach_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-hdpi/ic_attach_file.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-hdpi/ic_exit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-hdpi/ic_messages.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-hdpi/ic_send.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-mdpi/ic_account.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_attach_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-mdpi/ic_attach_file.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-mdpi/ic_exit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-mdpi/ic_messages.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-mdpi/ic_send.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/chandler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-v24/chandler.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/joey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-v24/joey.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-v24/ross.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xhdpi/ic_account.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_attach_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xhdpi/ic_attach_file.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xhdpi/ic_exit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xhdpi/ic_messages.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xhdpi/ic_send.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xxhdpi/ic_account.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_attach_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xxhdpi/ic_attach_file.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xxhdpi/ic_exit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xxhdpi/ic_messages.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable-xxhdpi/ic_send.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/default_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable/default_avatar.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/gunther.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable/gunther.png -------------------------------------------------------------------------------- /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/drawable/ic_menu_camera.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_gallery.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_manage.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_send.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_share.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_slideshow.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/img.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/janice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable/janice.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/monica.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable/monica.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/phoebe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable/phoebe.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/rachel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-tiurin/espresso-guide/f340ef61af65cd78746ce5034ad8de8f8af42bdc/app/src/main/res/drawable/rachel.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/side_nav_bar.xml: -------------------------------------------------------------------------------- 1 | 3 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_chat.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 22 | 30 | 39 | 40 | 41 | 42 | 50 | 51 | 52 | 60 | 61 | 63 | 64 | 69 | 70 | 80 | 96 | 97 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 18 | 19 | 26 | 27 |