diff --git a/app/build.gradle b/app/build.gradle index 86345680..c81401a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,8 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + signingConfigs { debug { storeFile file("debug.keystore") diff --git a/app/src/androidTest/java/com/jraska/github/client/DecoratedServiceModelFactory.kt b/app/src/androidTest/java/com/jraska/github/client/DecoratedServiceModelFactory.kt new file mode 100644 index 00000000..a9ea1993 --- /dev/null +++ b/app/src/androidTest/java/com/jraska/github/client/DecoratedServiceModelFactory.kt @@ -0,0 +1,18 @@ +package com.jraska.github.client + +import com.jraska.github.client.core.android.ServiceModel +import com.jraska.github.client.push.PushHandleModel +import com.jraska.github.client.xpush.PushAwaitRule + +class DecoratedServiceModelFactory( + private val productionFactory: ServiceModel.Factory +) : ServiceModel.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass == PushHandleModel::class.java) { + return PushAwaitRule.TestPushHandleModel(productionFactory.create(modelClass)) as T + } + return productionFactory.create(modelClass) + } +} diff --git a/app/src/androidTest/java/com/jraska/github/client/TestUITestApp.kt b/app/src/androidTest/java/com/jraska/github/client/TestUITestApp.kt index 68350ddd..e8dac332 100644 --- a/app/src/androidTest/java/com/jraska/github/client/TestUITestApp.kt +++ b/app/src/androidTest/java/com/jraska/github/client/TestUITestApp.kt @@ -1,10 +1,18 @@ package com.jraska.github.client import androidx.test.platform.app.InstrumentationRegistry +import com.jraska.github.client.core.android.ServiceModel import com.jraska.github.client.http.ReplayHttpComponent class TestUITestApp : GitHubClientApp() { val coreComponent = FakeCoreComponent() + val decoratedServiceFactory by lazy { + DecoratedServiceModelFactory(super.serviceModelFactory()) + } + + override fun serviceModelFactory(): ServiceModel.Factory { + return decoratedServiceFactory + } override fun retrofit(): HasRetrofit { return ReplayHttpComponent.create() diff --git a/app/src/androidTest/java/com/jraska/github/client/xpush/PushAwaitRule.kt b/app/src/androidTest/java/com/jraska/github/client/xpush/PushAwaitRule.kt new file mode 100644 index 00000000..bb026634 --- /dev/null +++ b/app/src/androidTest/java/com/jraska/github/client/xpush/PushAwaitRule.kt @@ -0,0 +1,47 @@ +package com.jraska.github.client.xpush + +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import androidx.test.espresso.idling.CountingIdlingResource +import androidx.test.internal.runner.junit4.statement.UiThreadStatement +import com.google.firebase.messaging.RemoteMessage +import com.jraska.github.client.push.PushHandleModel +import org.junit.rules.ExternalResource + +class PushAwaitRule : ExternalResource() { + override fun before() { + IdlingRegistry.getInstance().register(PushAwaitIdlingResource.idlingResource()) + } + + override fun after() { + IdlingRegistry.getInstance().unregister(PushAwaitIdlingResource.idlingResource()) + } + + fun waitForPush() { + PushAwaitIdlingResource.waitForPush() + } + + private object PushAwaitIdlingResource { + private val countingIdlingResource = CountingIdlingResource("Push Await") + + fun idlingResource(): IdlingResource = countingIdlingResource + + fun waitForPush() = countingIdlingResource.increment() + + fun onPush() { + // Doing this to make sure anything scheduled on UI thread will run before this + UiThreadStatement.runOnUiThread { + countingIdlingResource.decrement() + } + } + } + + class TestPushHandleModel( + val productionModel: PushHandleModel, + ) : PushHandleModel by productionModel { + override fun onMessageReceived(message: RemoteMessage) { + productionModel.onMessageReceived(message) + PushAwaitIdlingResource.onPush() + } + } +} diff --git a/app/src/androidTest/java/com/jraska/github/client/xpush/PushIntegrationTest.kt b/app/src/androidTest/java/com/jraska/github/client/xpush/PushIntegrationTest.kt new file mode 100644 index 00000000..b93cf857 --- /dev/null +++ b/app/src/androidTest/java/com/jraska/github/client/xpush/PushIntegrationTest.kt @@ -0,0 +1,73 @@ +package com.jraska.github.client.xpush + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.tasks.Tasks +import com.google.firebase.iid.FirebaseInstanceId +import com.jraska.github.client.DeepLinkLaunchTest +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class PushIntegrationTest { + + lateinit var pushClient: PushServerClient + lateinit var thisDeviceToken: String + + @get:Rule + val pushRule = PushAwaitRule() + + @Before + fun setUp() { + pushClient = PushServerClient.create(apiKey()) + + val instanceIdTask = FirebaseInstanceId.getInstance().instanceId + thisDeviceToken = Tasks.await(instanceIdTask).token + } + + @Test + fun testPushIntegration_fromSettingsToAbout() { + DeepLinkLaunchTest.launchDeepLink("https://github.com/settings") + + sendDeepLinkPush("https://github.com/about") + + awaitPush() + onView(withText("by Josef Raska")).check(matches(isDisplayed())) + } + + @Test + fun testPushIntegration_fromAboutToSettings() { + DeepLinkLaunchTest.launchDeepLink("https://github.com/about") + + sendDeepLinkPush("https://github.com/settings") + + awaitPush() + onView(withText("Purchase")).check(matches(isDisplayed())) + } + + // See LaunchDeepLinkCommand to see how this is handled. + private fun sendDeepLinkPush(deepLink: String) { + val messageToThisDevice = PushServerDto().apply { + ids.add(thisDeviceToken) + data["action"] = "launch_deep_link" + data["deepLink"] = deepLink + } + + pushClient.sendPush(messageToThisDevice).blockingAwait() + } + + private fun apiKey(): String { + val apiKey = InstrumentationRegistry.getArguments()["FCM_API_KEY"] + Assume.assumeTrue("FCM key not found in argument 'FCM_API_KEY', ignoring the test.", apiKey is String) + + return apiKey as String + } + + private fun awaitPush() { + pushRule.waitForPush() + } +} diff --git a/app/src/androidTest/java/com/jraska/github/client/xpush/PushServerClient.kt b/app/src/androidTest/java/com/jraska/github/client/xpush/PushServerClient.kt new file mode 100644 index 00000000..68347ee1 --- /dev/null +++ b/app/src/androidTest/java/com/jraska/github/client/xpush/PushServerClient.kt @@ -0,0 +1,39 @@ +package com.jraska.github.client.xpush + +import io.reactivex.Completable +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.Body +import retrofit2.http.POST +import timber.log.Timber + +interface PushServerClient { + @POST("/fcm/send") + fun sendPush(@Body message: PushServerDto): Completable + + companion object { + fun create(authorizationToken: String): PushServerClient { + return Retrofit.Builder().validateEagerly(true) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .baseUrl("https://fcm.googleapis.com") + .client( + OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .addHeader("Authorization", "key=$authorizationToken") + .build() + ) + } + .addInterceptor(HttpLoggingInterceptor { Timber.d(it) }.setLevel(HttpLoggingInterceptor.Level.BASIC)) + .build() + ) + .build() + .create(PushServerClient::class.java) + } + } +} diff --git a/app/src/androidTest/java/com/jraska/github/client/xpush/PushServerDto.kt b/app/src/androidTest/java/com/jraska/github/client/xpush/PushServerDto.kt new file mode 100644 index 00000000..351bc086 --- /dev/null +++ b/app/src/androidTest/java/com/jraska/github/client/xpush/PushServerDto.kt @@ -0,0 +1,11 @@ +package com.jraska.github.client.xpush + +import com.google.gson.annotations.SerializedName + +class PushServerDto { + @SerializedName("registration_ids") + val ids = mutableListOf() + + @SerializedName("data") + val data = mutableMapOf() +} diff --git a/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModel.kt b/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModel.kt index 9e72d6ee..60a37e10 100644 --- a/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModel.kt +++ b/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModel.kt @@ -2,23 +2,9 @@ package com.jraska.github.client.push import com.google.firebase.messaging.RemoteMessage import com.jraska.github.client.core.android.ServiceModel -import javax.inject.Inject -internal class PushHandleModel @Inject constructor( - private val pushHandler: PushHandler, - private val analytics: PushAnalytics, - private val tokenSynchronizer: PushTokenSynchronizer -) : ServiceModel { - fun onMessageReceived(remoteMessage: RemoteMessage) { - val action = RemoteMessageToActionConverter.convert(remoteMessage) +interface PushHandleModel : ServiceModel { + fun onMessageReceived(message: RemoteMessage) - val pushResult = pushHandler.handlePush(action) - - analytics.onPushHandled(action, pushResult) - } - - fun onNewToken(token: String) { - analytics.onTokenRefresh() - tokenSynchronizer.onTokenRefresh(token) - } + fun onNewToken(token: String) } diff --git a/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModelImpl.kt b/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModelImpl.kt new file mode 100644 index 00000000..dbab7641 --- /dev/null +++ b/feature/push/src/main/java/com/jraska/github/client/push/PushHandleModelImpl.kt @@ -0,0 +1,24 @@ +package com.jraska.github.client.push + +import com.google.firebase.messaging.RemoteMessage +import com.jraska.github.client.core.android.ServiceModel +import javax.inject.Inject + +internal class PushHandleModelImpl @Inject constructor( + private val pushHandler: PushHandler, + private val analytics: PushAnalytics, + private val tokenSynchronizer: PushTokenSynchronizer +) : PushHandleModel, ServiceModel { + override fun onMessageReceived(message: RemoteMessage) { + val action = RemoteMessageToActionConverter.convert(message) + + val pushResult = pushHandler.handlePush(action) + + analytics.onPushHandled(action, pushResult) + } + + override fun onNewToken(token: String) { + analytics.onTokenRefresh() + tokenSynchronizer.onTokenRefresh(token) + } +} diff --git a/feature/push/src/main/java/com/jraska/github/client/push/PushHandler.kt b/feature/push/src/main/java/com/jraska/github/client/push/PushHandler.kt index d9fb3ad3..ee654b4b 100644 --- a/feature/push/src/main/java/com/jraska/github/client/push/PushHandler.kt +++ b/feature/push/src/main/java/com/jraska/github/client/push/PushHandler.kt @@ -1,20 +1,22 @@ package com.jraska.github.client.push -import com.jraska.github.client.analytics.EventAnalytics import com.jraska.github.client.common.BooleanResult import timber.log.Timber import javax.inject.Inject import javax.inject.Provider class PushHandler @Inject internal constructor( - private val eventAnalytics: EventAnalytics, private val pushCommands: Map> ) { internal fun handlePush(action: PushAction): BooleanResult { - Timber.v("Push received action: %s", action.name) + Timber.d("Push received action: %s", action.name) - return handleInternal(action) + val result = handleInternal(action) + + Timber.d("Push result: %s, Action: %s", result, action) + + return result } private fun handleInternal(action: PushAction): BooleanResult { diff --git a/feature/push/src/main/java/com/jraska/github/client/push/PushModule.kt b/feature/push/src/main/java/com/jraska/github/client/push/PushModule.kt index 78bc0f31..8f8a6de2 100644 --- a/feature/push/src/main/java/com/jraska/github/client/push/PushModule.kt +++ b/feature/push/src/main/java/com/jraska/github/client/push/PushModule.kt @@ -37,6 +37,9 @@ object PushModule { @ClassKey(PushHandleModel::class) internal abstract fun bindServiceModel(pushHandleModel: PushHandleModel): ServiceModel + @Binds + internal abstract fun bindPushModel(pushHandleModel: PushHandleModelImpl): PushHandleModel + @Binds @IntoMap @StringKey("refresh_config") diff --git a/firebasePlugin/src/main/java/com/jraska/github/client/firebase/FirebaseTestLabPlugin.kt b/firebasePlugin/src/main/java/com/jraska/github/client/firebase/FirebaseTestLabPlugin.kt index 905d821d..59cacb3a 100644 --- a/firebasePlugin/src/main/java/com/jraska/github/client/firebase/FirebaseTestLabPlugin.kt +++ b/firebasePlugin/src/main/java/com/jraska/github/client/firebase/FirebaseTestLabPlugin.kt @@ -32,6 +32,8 @@ class FirebaseTestLabPlugin : Plugin { val device = "model=$deviceName,version=$androidVersion,locale=en,orientation=portrait" val resultDir = DateTimeFormatter.ISO_DATE_TIME.format(LocalDateTime.now()) + val fcmKey = System.getenv("FCM_API_KEY") + resultsFileToPull = "gs://test-lab-twsawhz0hy5am-h35y3vymzadax/$resultDir/$deviceName-$androidVersion-en-portrait/test_result_1.xml" it.commandLine = @@ -41,7 +43,8 @@ class FirebaseTestLabPlugin : Plugin { "--test $testApk " + "--device $device " + "--results-dir $resultDir " + - "--no-performance-metrics") + "--no-performance-metrics " + + "--environment-variables FCM_API_KEY=$fcmKey") .split(' ') it.dependsOn(project.tasks.named("assembleDebugAndroidTest"))