diff --git a/.github/workflows/dev-push.yml b/.github/workflows/dev-push.yml index ee106e8..2fc645b 100644 --- a/.github/workflows/dev-push.yml +++ b/.github/workflows/dev-push.yml @@ -4,6 +4,7 @@ on: push: paths-ignore: - '.github/**' + - 'README.md' branches: [ "dev" ] jobs: diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml index 394ab02..e34653c 100644 --- a/.github/workflows/main-push.yml +++ b/.github/workflows/main-push.yml @@ -4,6 +4,7 @@ on: push: paths-ignore: - '.github/**' + - 'README.md' branches: [ "main" ] workflow_dispatch: diff --git a/.gitignore b/.gitignore index 18d0cef..7db48e0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ local.properties .gradle build pusher-notif.txt -*.jks \ No newline at end of file +*.jks +.aiexclude \ No newline at end of file diff --git a/README.md b/README.md index a912002..4867138 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,11 @@ # Update -Rewriting app with clean architecture, acceptance test driven development and continous integration/delivery using Github Actions and Firebase App Distribution. -To access the old version, navigate to this [commit](https://github.com/syedahmedjamil/pushier/commit/ea7f30f8890fba63ff5571d64c3dffbe08dd9bfd) and click "Browse Files". +Updating app using clean architecture, acceptance test driven development and continuous integration/delivery using Github Actions and Firebase App Distribution. To see the old version, navigate to this [commit](https://github.com/syedahmedjamil/pushier/commit/ea7f30f8890fba63ff5571d64c3dffbe08dd9bfd) and click "Browse Files". # About Android client app that integrates with pusher beams to show realtime in-app notifications. -# Before building the app -1. configure fcm https://pusher.com/docs/beams/getting-started/android/configure-fcm/?ref=docs-index#open-firebase-console -2. make sure when creating app in firebase the **"Android package name"** is same as you set in **build.grade** `applicationId` -3. paste your fcm server key in your pusher beams instance "Settings" page under "Google FCM Integration" field -4. download and store your `google-services.json` file in the app folder +# Tests (new) +![](https://github.com/syedahmedjamil/pushier/blob/main/extras/tests.gif) -# Payload -> NOTE: your interest name has to be present in the `interests` array as well as in `fcm.data.interest` as shown below for it to work properly. -```json -{ - "interests": [ - "reddit" - ], - "fcm": { - "data": { - "interest": "reddit", - "category": "Important", - "date": "1/1/2022", - "title": "How to optimise text size on lower dpi devices", - "body": "In XML, is there any other way than defining new layouts files for different dpi devices just to handle text sizes as it completely messes up the entire layout if not handled properly?", - "subtext": "r/androiddev", - "link": "https://www.reddit.com/r/androiddev/comments/13a3p1c/how_to_optimise_text_size_on_lower_dpi_devices", - "image": "https://media.glassdoor.com/sql/796358/reddit-squarelogo-1490630845152.png" - } - } -} -``` -Sample using curl is located in : https://github.com/syedahmedjamil/pushier/blob/main/extras/payload%20sample%20using%20curl.bat. - -To use this sample you will need `PUSHER-BEAMS-INSTANCE-PRIMARY-KEY` and `PUSHER-BEAMS-INSTANCE-ID` both of which can be found by going to the "Keys" page of your pusher beams instance - -# Demo +# Demo (old) ![](https://github.com/syedahmedjamil/pushier/blob/main/extras/demo.gif) diff --git a/app/.gitignore b/app/.gitignore index 202e268..815f45f 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -2,4 +2,5 @@ .gradle *.jks *.json -misc \ No newline at end of file +misc +.aiexclude \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f695316..7cb1a2c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { id("com.google.gms.google-services") id("com.google.firebase.crashlytics") id("com.google.firebase.appdistribution") + id("androidx.navigation.safeargs.kotlin") } val keystorePropertiesFile = rootProject.file("app/keystore.properties") val keystoreProperties = Properties() @@ -22,7 +23,7 @@ android { minSdk = 24 targetSdk = 33 versionCode = project.property("versionCode").toString().toInt() - versionName = "1.0.0" + versionName = "1.2.0" testApplicationId = "com.github.syedahmedjamil.pushernotif.test" //testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "io.cucumber.android.runner.CucumberAndroidJUnitRunner" @@ -107,6 +108,12 @@ android { implementation(libs.androidx.constraintlayout) implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2") // for asLiveData method + implementation("com.google.firebase:firebase-iid:21.1.0") + implementation(platform("com.google.firebase:firebase-bom:32.7.0")) + implementation("com.google.firebase:firebase-messaging") + implementation("com.pusher:push-notifications-android:1.9.2") + implementation("androidx.navigation:navigation-ui-ktx:2.7.6") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.6") //test testImplementation(project(":shared-test")) @@ -119,8 +126,10 @@ android { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.cucumber.android) + androidTestImplementation(libs.androidx.rules) androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") - + androidTestImplementation("com.squareup.okhttp3:okhttp:3.12.0") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0-alpha03") } -} \ No newline at end of file +} diff --git a/app/src/androidTest/assets/.gitignore b/app/src/androidTest/assets/.gitignore new file mode 100644 index 0000000..b626e6a --- /dev/null +++ b/app/src/androidTest/assets/.gitignore @@ -0,0 +1 @@ +test.properties \ No newline at end of file diff --git a/app/src/androidTest/assets/features/instance/subscribe.feature b/app/src/androidTest/assets/features/instance/subscribe.feature new file mode 100644 index 0000000..8618e9a --- /dev/null +++ b/app/src/androidTest/assets/features/instance/subscribe.feature @@ -0,0 +1,30 @@ +Feature: Subscribe + User subscribes to a pusher instance using instance id + + Background: + Given I am on the "Instance" screen + + Scenario: User subscribes with empty instance id + Given I set "" as instance id + When I try to subscribe + Then I should see message "Please enter your Pusher Instance ID." + + Scenario: User subscribes with empty interests + Given I set "00000000-0000-0000-0000-000000000000" as instance id + When I try to subscribe + Then I should see message "Please add at least 1 interest before subscribing." + + Scenario: User subscribes with no internet access + Given Internet connection is turned "off" + And I set "00000000-0000-0000-0000-000000000000" as instance id + And I add "test" as an interest + When I try to subscribe + Then I should see message "Network unavailable." + + Scenario: User subscribes with valid data and internet access + Given Internet connection is turned "on" + And I set "00000000-0000-0000-0000-000000000000" as instance id + And I add "test" as an interest + When I try to subscribe + Then I am on the "Notifications" screen + diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/acceptance/FeatureInstanceSteps.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/acceptance/FeatureInstanceSteps.kt index 0b8d9d9..9bde159 100644 --- a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/acceptance/FeatureInstanceSteps.kt +++ b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/acceptance/FeatureInstanceSteps.kt @@ -4,23 +4,22 @@ import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import com.github.syedahmedjamil.pushernotif.MyApplication import com.github.syedahmedjamil.pushernotif.test.dsl.CucumberDsl -import com.github.syedahmedjamil.pushernotif.ui.instance.InstanceActivity +import com.github.syedahmedjamil.pushernotif.ui.MainActivity import io.cucumber.java.After import io.cucumber.java.Before import io.cucumber.java.en.Given import io.cucumber.java.en.Then import io.cucumber.java.en.When -import io.cucumber.java.PendingException import io.cucumber.java.en.And class FeatureInstanceSteps { private val dsl = CucumberDsl() - private lateinit var activityScenario: ActivityScenario + private lateinit var activityScenario: ActivityScenario @Before fun setup() { - activityScenario = ActivityScenario.launch(InstanceActivity::class.java) + activityScenario = ActivityScenario.launch(MainActivity::class.java) } @After @@ -31,7 +30,7 @@ class FeatureInstanceSteps { @Given("I am on the {string} screen") fun iAmOnThePage(arg0: String) { - dsl.instance.assertTitle(arg0) + dsl.instance.assertScreenTitle(arg0) } @When("I add {string} as an interest") @@ -58,4 +57,20 @@ class FeatureInstanceSteps { fun iShouldNotSeeAsAnInterest(arg0: String) { dsl.instance.assertInterestNotListed(arg0) } + + @Given("I set {string} as instance id") + fun iSetAsInstanceId(arg0: String) { + dsl.instance.setInstanceId(arg0) + } + + @When("I try to subscribe") + fun iTryToSubscribe() { + dsl.instance.subscribe() + + } + + @Given("Internet connection is turned {string}") + fun internetConnectionIsTurned(arg0: String) { + dsl.instance.setInternetConnection(arg0) + } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/AppContainerTest.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/AppContainerTest.kt similarity index 60% rename from app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/AppContainerTest.kt rename to app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/AppContainerTest.kt index e535087..7707626 100644 --- a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/AppContainerTest.kt +++ b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/AppContainerTest.kt @@ -1,14 +1,13 @@ -package com.github.syedahmedjamil.pushernotif +package com.github.syedahmedjamil.pushernotif.test.integration import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.github.syedahmedjamil.pushernotif.AppContainer import com.github.syedahmedjamil.pushernotif.usecases.AddInterestUseCase import com.github.syedahmedjamil.pushernotif.usecases.GetInterestsUseCase import com.github.syedahmedjamil.pushernotif.usecases.RemoveInterestUseCase -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue +import com.github.syedahmedjamil.pushernotif.usecases.SubscribeUseCase +import org.junit.Assert import org.junit.Test class AppContainerTest { @@ -18,30 +17,38 @@ class AppContainerTest { @Test fun test_appContainer_is_not_null() { - assertNotNull(appContainer) + Assert.assertNotNull(appContainer) } @Test fun test_appContainer_has_a_addInterestUseCase_singleton() { val useCase1: AddInterestUseCase = appContainer.addInterestUseCase val useCase2: AddInterestUseCase = appContainer.addInterestUseCase - assertNotNull(useCase1) - assertEquals(useCase1, useCase2) + Assert.assertNotNull(useCase1) + Assert.assertEquals(useCase1, useCase2) } @Test fun test_appContainer_has_a_getInterestsUseCase_singleton() { val useCase1: GetInterestsUseCase = appContainer.getInterestsUseCase val useCase2: GetInterestsUseCase = appContainer.getInterestsUseCase - assertNotNull(useCase1) - assertEquals(useCase1, useCase2) + Assert.assertNotNull(useCase1) + Assert.assertEquals(useCase1, useCase2) } @Test fun test_appContainer_has_a_removeInterestUseCase_singleton() { val useCase1: RemoveInterestUseCase = appContainer.removeInterestUseCase val useCase2: RemoveInterestUseCase = appContainer.removeInterestUseCase - assertNotNull(useCase1) - assertEquals(useCase1, useCase2) + Assert.assertNotNull(useCase1) + Assert.assertEquals(useCase1, useCase2) + } + + @Test + fun test_appContainer_has_a_subscribeUseCase_singleton() { + val useCase1: SubscribeUseCase = appContainer.subscribeUseCase + val useCase2: SubscribeUseCase = appContainer.subscribeUseCase + Assert.assertNotNull(useCase1) + Assert.assertEquals(useCase1, useCase2) } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/InterestLocalDataSourceTest.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/InterestLocalDataSourceTest.kt similarity index 92% rename from app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/InterestLocalDataSourceTest.kt rename to app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/InterestLocalDataSourceTest.kt index f794769..edb4d32 100644 --- a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/InterestLocalDataSourceTest.kt +++ b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/InterestLocalDataSourceTest.kt @@ -1,4 +1,4 @@ -package com.github.syedahmedjamil.pushernotif +package com.github.syedahmedjamil.pushernotif.test.integration import android.content.Context import androidx.datastore.core.DataStore @@ -20,7 +20,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.assertEquals +import org.junit.Assert import org.junit.Before import org.junit.Test import java.io.File @@ -78,7 +78,7 @@ class InterestLocalDataSourceTest { // when val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @Test @@ -89,7 +89,7 @@ class InterestLocalDataSourceTest { // when val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @@ -102,7 +102,7 @@ class InterestLocalDataSourceTest { interestLocalDataSource.addInterest(interest) val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @Test @@ -115,7 +115,7 @@ class InterestLocalDataSourceTest { interestLocalDataSource.addInterest(interest) val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @Test @@ -128,7 +128,7 @@ class InterestLocalDataSourceTest { interestLocalDataSource.removeInterest(interest) val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @Test @@ -141,7 +141,7 @@ class InterestLocalDataSourceTest { interestLocalDataSource.removeInterest(interest) val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @Test @@ -154,7 +154,7 @@ class InterestLocalDataSourceTest { interestLocalDataSource.removeInterest(interest) val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } @Test @@ -171,7 +171,7 @@ class InterestLocalDataSourceTest { interestLocalDataSource.removeInterest(interest3) val actual = interestLocalDataSource.getInterests().first() // then - assertEquals(expected, actual) + Assert.assertEquals(expected, actual) } diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/SubscribeServiceImplTest.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/SubscribeServiceImplTest.kt new file mode 100644 index 0000000..bc91575 --- /dev/null +++ b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/integration/SubscribeServiceImplTest.kt @@ -0,0 +1,119 @@ +package com.github.syedahmedjamil.pushernotif.test.integration + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ServiceTestRule +import androidx.test.uiautomator.UiDevice +import com.github.syedahmedjamil.pushernotif.domain.SubscribeService +import com.github.syedahmedjamil.pushernotif.framework.MyPusherMessagingService +import com.github.syedahmedjamil.pushernotif.framework.SubscribeServiceImpl +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.junit.Assert +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import java.util.Properties + +/* +Waits or delays are not good in a test. Unable to mock fcm so adding some waits to make it more +reliable. +*/ + +class SubscribeServiceImplTest { + private lateinit var service: SubscribeService + private lateinit var context: Context + + @get:Rule + val serviceRule = ServiceTestRule() + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + service = SubscribeServiceImpl(context) + } + + @Ignore("passing locally on physical and virtual devices but failing on github actions") + @Test + fun should_subscribe() = runBlocking { + // given + val contextForFile = InstrumentationRegistry.getInstrumentation().context + val file = contextForFile.assets.open("test.properties") + val properties = Properties() + properties.load(file) + val accessToken = properties["accessToken"] as String + val instanceId = properties["instanceId"] as String + val interests = listOf("debug-test") + val serviceIntent = + Intent( + ApplicationProvider.getApplicationContext(), + MyPusherMessagingService::class.java + ) + + // when + serviceRule.startService(serviceIntent) + Thread.sleep(10000) + service.subscribe(instanceId, interests) + Thread.sleep(5000) + sendPush(instanceId, interests[0], accessToken) + Thread.sleep(5000) + + // then + Assert.assertTrue(MyPusherMessagingService.isOnMessageReceivedCalled) + Assert.assertEquals(MyPusherMessagingService.data["title"], "Test Title") + } + + private fun sendPush(instanceId: String, interest: String, accessToken: String) { + val client = OkHttpClient(); + val mediaType = MediaType.get("application/json") + val url = getUrl(instanceId) + + val body = RequestBody.create(mediaType, getRequestBody(interest)) + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $accessToken") + .post(body) + .build() + try { + val response = client.newCall(request).execute() + } catch (e: Exception) { + + } + + } + + private fun getUrl(instanceId: String): String { + return "https://${instanceId}.pushnotifications.pusher.com/publish_api/v1/instances/${instanceId}/publishes" + } + + private fun getRequestBody(interest: String): String { + val body = "{\n" + + " \"interests\": [\n" + + " \"${interest}\"\n" + + " ],\n" + + " \"fcm\": {\n" + + " \"data\": {\n" + + " \"interest\": \"${interest}\",\n" + + " \"category\": \"test\",\n" + + " \"date\": \"1/1/2024\",\n" + + " \"title\": \"Test Title\",\n" + + " \"body\": \"Test Body\",\n" + + " \"subtext\": \"Test Subtext\",\n" + + " \"link\": \"https://www.test.com\",\n" + + " \"image\": \"https://test.png\"\n" + + " }\n" + + " }\n" + + "}" + + return body + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/driver/InstanceScreenDriver.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/driver/InstanceScreenDriver.kt similarity index 60% rename from app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/driver/InstanceScreenDriver.kt rename to app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/driver/InstanceScreenDriver.kt index f46d2fd..3cf8810 100644 --- a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/driver/InstanceScreenDriver.kt +++ b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/driver/InstanceScreenDriver.kt @@ -8,6 +8,9 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until import com.github.syedahmedjamil.pushernotif.R import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.equalTo @@ -19,11 +22,16 @@ import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.not import org.hamcrest.Matchers.startsWith +// Instead of Thread.sleep() use UiDevice.wait() and IdlingResource for navigation and databinding class InstanceScreenDriver { - fun navigateToScreen(arg0: String) { - onView(withId(R.id.instance_toolbar)).check(matches(hasDescendant(withText(arg0)))) + fun assertScreenTitle(arg0: String) { + Thread.sleep(3000) + if(arg0 == "Instance") + onView(withId(R.id.instance_toolbar)).check(matches(hasDescendant(withText(arg0)))) + if(arg0 == "Notifications") + onView(withId(R.id.notification_toolbar)).check(matches(hasDescendant(withText(arg0)))) } fun addInterest(arg0: String) { @@ -50,5 +58,23 @@ class InstanceScreenDriver { onView(withId(R.id.instance_interests_list_view)) .check(matches(not(hasDescendant(withText(arg0))))) } + + fun setInstanceId(arg0: String) { + onView(withId(R.id.instance_id_edit_text)).perform(replaceText(arg0)) + } + + fun subscribe() { + onView(withId(R.id.instance_subscribe_button)).perform(click()) + } + + fun setInternetConnection(arg0: String) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + if (arg0 == "on") + device.executeShellCommand("cmd connectivity airplane-mode disable") + if (arg0 == "off") + device.executeShellCommand("cmd connectivity airplane-mode enable") + + Thread.sleep(10000) + } } diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/dsl/CucumberDsl.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/dsl/CucumberDsl.kt similarity index 100% rename from app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/dsl/CucumberDsl.kt rename to app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/dsl/CucumberDsl.kt diff --git a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/dsl/InstanceScreenDsl.kt b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/dsl/InstanceScreenDsl.kt similarity index 67% rename from app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/dsl/InstanceScreenDsl.kt rename to app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/dsl/InstanceScreenDsl.kt index acf3f5a..366cc93 100644 --- a/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/dsl/InstanceScreenDsl.kt +++ b/app/src/androidTest/java/com/github/syedahmedjamil/pushernotif/test/util/dsl/InstanceScreenDsl.kt @@ -5,8 +5,8 @@ import com.github.syedahmedjamil.pushernotif.test.driver.InstanceScreenDriver class InstanceScreenDsl { private val driver = InstanceScreenDriver() - fun assertTitle(arg0: String) { - driver.navigateToScreen(arg0) + fun assertScreenTitle(arg0: String) { + driver.assertScreenTitle(arg0) } fun addInterest(arg0: String) { @@ -28,4 +28,17 @@ class InstanceScreenDsl { fun assertInterestNotListed(arg0: String) { driver.assertInterestNotListed(arg0) } + + fun setInstanceId(arg0: String) { + driver.setInstanceId(arg0) + } + + fun subscribe() { + driver.subscribe() + + } + + fun setInternetConnection(arg0: String) { + driver.setInternetConnection(arg0) + } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ec262b6..85c16bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:theme="@style/Theme.PusherNotif" tools:targetApi="31"> @@ -22,5 +22,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/AppContainer.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/AppContainer.kt index 15d342e..3702ab8 100644 --- a/app/src/main/java/com/github/syedahmedjamil/pushernotif/AppContainer.kt +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/AppContainer.kt @@ -7,9 +7,11 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStoreFile import com.github.syedahmedjamil.pushernotif.data.InterestRepositoryImpl import com.github.syedahmedjamil.pushernotif.framework.InterestLocalDataSource +import com.github.syedahmedjamil.pushernotif.framework.SubscribeServiceImpl import com.github.syedahmedjamil.pushernotif.usecases.AddInterestUseCaseImpl import com.github.syedahmedjamil.pushernotif.usecases.GetInterestsUseCaseImpl import com.github.syedahmedjamil.pushernotif.usecases.RemoveInterestUseCaseImpl +import com.github.syedahmedjamil.pushernotif.usecases.SubscribeUseCaseImpl import kotlinx.coroutines.runBlocking private const val INTEREST_DATASTORE_NAME = "interest" @@ -24,10 +26,12 @@ class AppContainer(context: Context) { private val interestLocalDataSource by lazy { InterestLocalDataSource(interestDataStore) } private val interestRepository by lazy { InterestRepositoryImpl(interestLocalDataSource) } + private val subscribeService by lazy { SubscribeServiceImpl(context) } val addInterestUseCase by lazy { AddInterestUseCaseImpl(interestRepository) } val getInterestsUseCase by lazy { GetInterestsUseCaseImpl(interestRepository) } val removeInterestUseCase by lazy { RemoveInterestUseCaseImpl(interestRepository) } + val subscribeUseCase by lazy { SubscribeUseCaseImpl(subscribeService) } // For testing @VisibleForTesting diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/framework/MyPusherMessagingService.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/framework/MyPusherMessagingService.kt new file mode 100644 index 0000000..2cc196c --- /dev/null +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/framework/MyPusherMessagingService.kt @@ -0,0 +1,19 @@ +package com.github.syedahmedjamil.pushernotif.framework + +import androidx.annotation.VisibleForTesting +import com.google.firebase.messaging.RemoteMessage +import com.pusher.pushnotifications.fcm.MessagingService + +class MyPusherMessagingService : MessagingService() { + + @VisibleForTesting + companion object { + var isOnMessageReceivedCalled = false + lateinit var data: MutableMap + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + data = remoteMessage.data + isOnMessageReceivedCalled = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/framework/SubscribeServiceImpl.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/framework/SubscribeServiceImpl.kt new file mode 100644 index 0000000..1857edf --- /dev/null +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/framework/SubscribeServiceImpl.kt @@ -0,0 +1,21 @@ +package com.github.syedahmedjamil.pushernotif.framework + +import android.content.Context +import android.net.ConnectivityManager +import androidx.core.content.ContextCompat.getSystemService +import com.github.syedahmedjamil.pushernotif.domain.SubscribeService +import com.pusher.pushnotifications.PushNotifications + +class SubscribeServiceImpl(private val context: Context) : SubscribeService { + override suspend fun subscribe(instanceId: String, interests: List) { + PushNotifications.start(context, instanceId) + PushNotifications.setDeviceInterests(interests.toSet()) + } + + override fun isNetworkAvailable(): Boolean { + val connectivityManager = + getSystemService(context, ConnectivityManager::class.java) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetwork + return activeNetwork != null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/MainActivity.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/MainActivity.kt new file mode 100644 index 0000000..fe02bd6 --- /dev/null +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/MainActivity.kt @@ -0,0 +1,18 @@ +package com.github.syedahmedjamil.pushernotif.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.github.syedahmedjamil.pushernotif.AppContainer +import com.github.syedahmedjamil.pushernotif.R +import com.github.syedahmedjamil.pushernotif.ui.instance.InstanceViewModel + +class MainActivity : AppCompatActivity() { + + private lateinit var viewModel: InstanceViewModel + private lateinit var appContainer: AppContainer + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceActivity.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceActivity.kt deleted file mode 100644 index 3ea56dd..0000000 --- a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.github.syedahmedjamil.pushernotif.ui.instance - -import android.os.Bundle -import android.widget.ArrayAdapter -import androidx.appcompat.app.AppCompatActivity -import androidx.databinding.DataBindingUtil -import androidx.lifecycle.ViewModelProvider -import com.github.syedahmedjamil.pushernotif.AppContainer -import com.github.syedahmedjamil.pushernotif.MyApplication -import com.github.syedahmedjamil.pushernotif.R -import com.github.syedahmedjamil.pushernotif.databinding.ActivityInstanceBinding -import com.google.android.material.snackbar.Snackbar - -class InstanceActivity : AppCompatActivity() { - - private lateinit var viewModel: InstanceViewModel - private lateinit var appContainer: AppContainer - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val binding: ActivityInstanceBinding = - DataBindingUtil.setContentView(this, R.layout.activity_instance) - binding.lifecycleOwner = this - - appContainer = (application as MyApplication).appContainer - - //manual DI - viewModel = ViewModelProvider( - this, - InstanceViewModel.InstanceViewModelFactory( - appContainer.addInterestUseCase, - appContainer.getInterestsUseCase, - appContainer.removeInterestUseCase - ) - )[InstanceViewModel::class.java] - - //bindings - binding.viewmodel = viewModel - binding.adapter = InstanceInterestListAdapter(this, R.layout.interest_list_item, viewModel) - - //observe - viewModel.errorMessage.observe(this) { - Snackbar.make(binding.main, it, Snackbar.LENGTH_SHORT).show() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceFragment.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceFragment.kt new file mode 100644 index 0000000..a268472 --- /dev/null +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceFragment.kt @@ -0,0 +1,104 @@ +package com.github.syedahmedjamil.pushernotif.ui.instance + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import com.github.syedahmedjamil.pushernotif.AppContainer +import com.github.syedahmedjamil.pushernotif.MyApplication +import com.github.syedahmedjamil.pushernotif.R +import com.github.syedahmedjamil.pushernotif.databinding.FragmentInstanceBinding +import com.google.android.material.snackbar.Snackbar + +// TODO: Rename parameter arguments, choose names that match +// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER +private const val ARG_PARAM1 = "param1" +private const val ARG_PARAM2 = "param2" + +/** + * A simple [Fragment] subclass. + * Use the [InstanceFragment.newInstance] factory method to + * create an instance of this fragment. + */ +class InstanceFragment : Fragment() { + private var param1: String? = null + private var param2: String? = null + + private lateinit var viewDataBinding: FragmentInstanceBinding + private lateinit var viewModel: InstanceViewModel + private lateinit var appContainer: AppContainer + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + param1 = it.getString(ARG_PARAM1) + param2 = it.getString(ARG_PARAM2) + } + + appContainer = (requireContext().applicationContext as MyApplication).appContainer + + //manual DI + viewModel = ViewModelProvider( + this, + InstanceViewModel.InstanceViewModelFactory( + appContainer.addInterestUseCase, + appContainer.getInterestsUseCase, + appContainer.removeInterestUseCase, + appContainer.subscribeUseCase + ) + )[InstanceViewModel::class.java] + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + viewDataBinding = + DataBindingUtil.inflate(inflater, R.layout.fragment_instance, container, false) + return viewDataBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewDataBinding.lifecycleOwner = this.viewLifecycleOwner + + //bindings + viewDataBinding.viewmodel = viewModel + viewDataBinding.adapter = InstanceInterestListAdapter(requireContext(), R.layout.interest_list_item, viewModel) + + //observe + viewModel.errorMessage.observe(viewLifecycleOwner) { + Snackbar.make(viewDataBinding.instanceConstraintLayout, it, Snackbar.LENGTH_SHORT).show() + } + + viewModel.subscribeEvent.observe(viewLifecycleOwner) { + it.getValueIfNotHandled()?.let { + val action = InstanceFragmentDirections.actionInstanceDestToNotificationsDest() + findNavController().navigate(action) + } + } + + } + + companion object { + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @param param1 Parameter 1. + * @param param2 Parameter 2. + * @return A new instance of fragment InstanceFragment. + */ + @JvmStatic + fun newInstance(param1: String, param2: String) = + InstanceFragment().apply { + arguments = Bundle().apply { + putString(ARG_PARAM1, param1) + putString(ARG_PARAM2, param2) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceViewModel.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceViewModel.kt index fcfa685..1894cbc 100644 --- a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceViewModel.kt +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/instance/InstanceViewModel.kt @@ -10,18 +10,25 @@ import com.github.syedahmedjamil.pushernotif.core.Result import com.github.syedahmedjamil.pushernotif.usecases.AddInterestUseCase import com.github.syedahmedjamil.pushernotif.usecases.GetInterestsUseCase import com.github.syedahmedjamil.pushernotif.usecases.RemoveInterestUseCase +import com.github.syedahmedjamil.pushernotif.usecases.SubscribeUseCase +import com.github.syedahmedjamil.pushernotif.util.Event +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @Suppress("UNCHECKED_CAST") class InstanceViewModel( private val addInterestUseCase: AddInterestUseCase, private val getInterestsUseCase: GetInterestsUseCase, - private val removeInterestUseCase: RemoveInterestUseCase + private val removeInterestUseCase: RemoveInterestUseCase, + private val subscribeUseCase: SubscribeUseCase ) : ViewModel() { private val _errorMessage = MutableLiveData() val errorMessage: LiveData = _errorMessage + private val _subscribeEvent = MutableLiveData>() + val subscribeEvent: LiveData> = _subscribeEvent + val interests = getInterestsUseCase().asLiveData() fun addInterest(interest: String) { @@ -42,6 +49,19 @@ class InstanceViewModel( } } + fun subscribe(instanceId: String) { + viewModelScope.launch { + val interests = interests.value ?: emptyList() + val result = subscribeUseCase(instanceId, interests) + if (result is Result.Error) { + displayError(result.exception.message) + } + if (result is Result.Success) { + _subscribeEvent.value = Event(Unit) + } + } + } + private fun displayError(message: String?) { message?.let { _errorMessage.value = it @@ -51,13 +71,15 @@ class InstanceViewModel( class InstanceViewModelFactory( private val addInterestUseCase: AddInterestUseCase, private val getInterestsUseCase: GetInterestsUseCase, - private val removeInterestUseCase: RemoveInterestUseCase + private val removeInterestUseCase: RemoveInterestUseCase, + private val subscribeUseCase: SubscribeUseCase ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return InstanceViewModel( addInterestUseCase, getInterestsUseCase, - removeInterestUseCase + removeInterestUseCase, + subscribeUseCase ) as T } } diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/notification/NotificationFragment.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/notification/NotificationFragment.kt new file mode 100644 index 0000000..e4cfbdf --- /dev/null +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/ui/notification/NotificationFragment.kt @@ -0,0 +1,60 @@ +package com.github.syedahmedjamil.pushernotif.ui.notification + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.github.syedahmedjamil.pushernotif.R + +// TODO: Rename parameter arguments, choose names that match +// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER +private const val ARG_PARAM1 = "param1" +private const val ARG_PARAM2 = "param2" + +/** + * A simple [Fragment] subclass. + * Use the [NotificationFragment.newInstance] factory method to + * create an instance of this fragment. + */ +class NotificationFragment : Fragment() { + // TODO: Rename and change types of parameters + private var param1: String? = null + private var param2: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + param1 = it.getString(ARG_PARAM1) + param2 = it.getString(ARG_PARAM2) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_notification, container, false) + } + + companion object { + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @param param1 Parameter 1. + * @param param2 Parameter 2. + * @return A new instance of fragment NotificationFragment. + */ + // TODO: Rename and change types and number of parameters + @JvmStatic + fun newInstance(param1: String, param2: String) = + NotificationFragment().apply { + arguments = Bundle().apply { + putString(ARG_PARAM1, param1) + putString(ARG_PARAM2, param2) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/syedahmedjamil/pushernotif/util/Event.kt b/app/src/main/java/com/github/syedahmedjamil/pushernotif/util/Event.kt new file mode 100644 index 0000000..65b13f1 --- /dev/null +++ b/app/src/main/java/com/github/syedahmedjamil/pushernotif/util/Event.kt @@ -0,0 +1,13 @@ +package com.github.syedahmedjamil.pushernotif.util + +class Event(private val value: T) { + private var isHandled = false + fun getValueIfNotHandled(): T? { + if (isHandled) return null + else { + isHandled = true + return value + } + } + fun getValueRegardless(): T? = value +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..9ffc090 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_instance.xml b/app/src/main/res/layout/fragment_instance.xml similarity index 63% rename from app/src/main/res/layout/activity_instance.xml rename to app/src/main/res/layout/fragment_instance.xml index b6c0e89..e7ee35b 100644 --- a/app/src/main/res/layout/activity_instance.xml +++ b/app/src/main/res/layout/fragment_instance.xml @@ -1,7 +1,8 @@ + xmlns:tools="http://schemas.android.com/tools" + tools:context=".ui.instance.InstanceFragment"> @@ -20,10 +21,10 @@ + android:layout_height="match_parent"> + + + + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"/> + app:layout_constraintStart_toStartOf="parent"/>