diff --git a/OneSignalSDK/onesignal/location/build.gradle b/OneSignalSDK/onesignal/location/build.gradle index e681ba3092..18dd55638a 100644 --- a/OneSignalSDK/onesignal/location/build.gradle +++ b/OneSignalSDK/onesignal/location/build.gradle @@ -82,4 +82,5 @@ dependencies { testImplementation("io.mockk:mockk:1.13.2") testImplementation("org.json:json:20180813") testImplementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + testImplementation("com.google.android.gms:play-services-location:18.0.0") } \ No newline at end of file diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt index 628036689c..bcf04e9565 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt @@ -43,7 +43,6 @@ internal interface ILocationPermissionChangedHandler { } internal class LocationPermissionController( - private val _application: IApplicationService, private val _requestPermission: IRequestPermissionService, private val _applicationService: IApplicationService ) : IRequestPermissionService.PermissionCallback, @@ -95,7 +94,7 @@ internal class LocationPermissionController( } private fun showFallbackAlertDialog(): Boolean { - val activity = _application.current ?: return false + val activity = _applicationService.current ?: return false AlertDialogPrepromptForAndroidSettings.show( activity, activity.getString(R.string.location_permission_name_for_title), diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/ExampleUnitTest.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/ExampleUnitTest.kt deleted file mode 100644 index 4ba0a25752..0000000000 --- a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.onesignal.location - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/extensions/ContainedRobolectricRunner.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/extensions/ContainedRobolectricRunner.kt new file mode 100644 index 0000000000..873c62cc6b --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/extensions/ContainedRobolectricRunner.kt @@ -0,0 +1,67 @@ +/** + * Code taken from https://github.com/kotest/kotest-extensions-robolectric with no changes. + * + * LICENSE: https://github.com/kotest/kotest-extensions-robolectric/blob/master/LICENSE + */ +package com.onesignal.notifications.extensions + +import org.junit.runners.model.FrameworkMethod +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.internal.bytecode.InstrumentationConfiguration +import org.robolectric.pluginapi.config.ConfigurationStrategy +import org.robolectric.plugins.ConfigConfigurer +import java.lang.reflect.Method + +internal class ContainedRobolectricRunner( + private val config: Config? +) : RobolectricTestRunner(PlaceholderTest::class.java, injector) { + private val placeHolderMethod: FrameworkMethod = children[0] + val sdkEnvironment = getSandbox(placeHolderMethod).also { + configureSandbox(it, placeHolderMethod) + } + private val bootStrapMethod = sdkEnvironment.bootstrappedClass(testClass.javaClass) + .getMethod(PlaceholderTest::bootStrapMethod.name) + + fun containedBefore() { + Thread.currentThread().contextClassLoader = sdkEnvironment.robolectricClassLoader + super.beforeTest(sdkEnvironment, placeHolderMethod, bootStrapMethod) + } + + fun containedAfter() { + super.afterTest(placeHolderMethod, bootStrapMethod) + super.finallyAfterTest(placeHolderMethod) + Thread.currentThread().contextClassLoader = ContainedRobolectricRunner::class.java.classLoader + } + + override fun createClassLoaderConfig(method: FrameworkMethod?): InstrumentationConfiguration { + return InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method)) + .doNotAcquirePackage("io.kotest") + .build() + } + + override fun getConfig(method: Method?): Config { + val defaultConfiguration = injector.getInstance(ConfigurationStrategy::class.java) + .getConfig(testClass.javaClass, method) + + if (config != null) { + val configConfigurer = injector.getInstance(ConfigConfigurer::class.java) + return configConfigurer.merge(defaultConfiguration[Config::class.java], config) + } + + return super.getConfig(method) + } + + class PlaceholderTest { + @org.junit.Test + fun testPlaceholder() { + } + + fun bootStrapMethod() { + } + } + + companion object { + private val injector = defaultInjector().build() + } +} diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/extensions/RobolectricExtension.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/extensions/RobolectricExtension.kt new file mode 100644 index 0000000000..ce40e0fc5f --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/extensions/RobolectricExtension.kt @@ -0,0 +1,96 @@ +/** + * Code taken from https://github.com/kotest/kotest-extensions-robolectric with a + * fix in the intercept method. + * + * LICENSE: https://github.com/kotest/kotest-extensions-robolectric/blob/master/LICENSE + */ +package com.onesignal.notifications.extensions + +import android.app.Application +import io.kotest.core.extensions.ConstructorExtension +import io.kotest.core.extensions.TestCaseExtension +import io.kotest.core.spec.AutoScan +import io.kotest.core.spec.Spec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import org.robolectric.annotation.Config +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation + +/** + * We override TestCaseExtension to configure the Robolectric environment because TestCase intercept + * occurs on the same thread the test is run. This is unfortunate because it is run for every test, + * rather than every spec. But the SpecExtension intercept is run on a different thread. + */ +@AutoScan +internal class RobolectricExtension : ConstructorExtension, TestCaseExtension { + private fun Class<*>.getParentClass(): List> { + if (superclass == null) return listOf() + return listOf(superclass) + superclass.getParentClass() + } + + private fun KClass<*>.getConfig(): Config { + val configAnnotations = listOf(this.java).plus(this.java.getParentClass()) + .mapNotNull { it.kotlin.findAnnotation() } + .asSequence() + + val configAnnotation = configAnnotations.firstOrNull() + + if (configAnnotation != null) { + return Config.Builder(configAnnotation).build() + } + + val robolectricTestAnnotations = listOf(this.java).plus(this.java.getParentClass()) + .mapNotNull { it.kotlin.findAnnotation() } + .asSequence() + + val application: KClass? = robolectricTestAnnotations + .firstOrNull { it.application != KotestDefaultApplication::class }?.application + val sdk: Int? = robolectricTestAnnotations.firstOrNull { it.sdk != -1 }?.takeUnless { it.sdk == -1 }?.sdk + + return Config.Builder() + .also { builder -> + if (application != null) { + builder.setApplication(application.java) + } + + if (sdk != null) { + builder.setSdk(sdk) + } + }.build() + } + + override fun instantiate(clazz: KClass): Spec? { + clazz.findAnnotation() ?: return null + + return ContainedRobolectricRunner(clazz.getConfig()) + .sdkEnvironment.bootstrappedClass(clazz.java).newInstance() + } + + override suspend fun intercept( + testCase: TestCase, + execute: suspend (TestCase) -> TestResult + ): TestResult { + // FIXED: Updated code based on https://github.com/kotest/kotest/issues/2717 + val hasRobolectricAnnotation = testCase.spec::class.annotations.any { annotation -> + annotation.annotationClass.qualifiedName == RobolectricTest::class.qualifiedName + } + + if (!hasRobolectricAnnotation) { + return execute(testCase) + } + + val containedRobolectricRunner = ContainedRobolectricRunner(testCase.spec::class.getConfig()) + containedRobolectricRunner.containedBefore() + val result = execute(testCase) + containedRobolectricRunner.containedAfter() + return result + } +} + +internal class KotestDefaultApplication : Application() + +annotation class RobolectricTest( + val application: KClass = KotestDefaultApplication::class, + val sdk: Int = -1 +) diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/background/LocationBackgroundServiceTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/background/LocationBackgroundServiceTests.kt new file mode 100644 index 0000000000..5c3524ad56 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/background/LocationBackgroundServiceTests.kt @@ -0,0 +1,107 @@ +package com.onesignal.location.internal.background + +import android.Manifest +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.location.ILocationManager +import com.onesignal.location.internal.capture.ILocationCapturer +import com.onesignal.location.internal.common.LocationConstants +import com.onesignal.location.internal.preferences.ILocationPreferencesService +import com.onesignal.mocks.AndroidMockHelper +import com.onesignal.mocks.MockHelper +import com.onesignal.notifications.extensions.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.runner.RunWith +import org.robolectric.Shadows + +@RobolectricTest +@RunWith(KotestTestRunner::class) +class LocationBackgroundServiceTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("backgroundRun will capture current location") { + /* Given */ + val mockLocationManager = mockk() + val mockLocationPreferencesService = mockk() + val mockLocationCapturer = mockk() + every { mockLocationCapturer.captureLastLocation() } just runs + + val locationBackgroundService = LocationBackgroundService( + AndroidMockHelper.applicationService(), + mockLocationManager, + mockLocationPreferencesService, + mockLocationCapturer, + MockHelper.time(1111) + ) + + /* When */ + locationBackgroundService.backgroundRun() + + /* Then */ + verify(exactly = 1) { mockLocationCapturer.captureLastLocation() } + } + + test("scheduleBackgroundRunIn will return null when location services are disabled in SDK") { + /* Given */ + val mockLocationManager = mockk() + every { mockLocationManager.isLocationShared } returns false + + val mockLocationPreferencesService = mockk() + val mockLocationCapturer = mockk() + + val locationBackgroundService = LocationBackgroundService( + AndroidMockHelper.applicationService(), + mockLocationManager, + mockLocationPreferencesService, + mockLocationCapturer, + MockHelper.time(1111) + ) + + /* When */ + val result = locationBackgroundService.scheduleBackgroundRunIn + + /* Then */ + result shouldBe null + verify(exactly = 1) { mockLocationManager.isLocationShared } + } + + test("scheduleBackgroundRunIn will return null when no android permissions") { + /* Given */ + val mockLocationManager = mockk() + every { mockLocationManager.isLocationShared } returns true + + val mockLocationPreferencesService = mockk() + every { mockLocationPreferencesService.lastLocationTime } returns 1111 + + val mockLocationCapturer = mockk() + + val application: Application = ApplicationProvider.getApplicationContext() + val app = Shadows.shadowOf(application) + app.grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + + val locationBackgroundService = LocationBackgroundService( + AndroidMockHelper.applicationService(), + mockLocationManager, + mockLocationPreferencesService, + mockLocationCapturer, + MockHelper.time(2222) + ) + + /* When */ + val result = locationBackgroundService.scheduleBackgroundRunIn + + /* Then */ + result shouldBe (1000 * LocationConstants.TIME_BACKGROUND_SEC) - (2222 - 1111) + } +}) diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/capture/LocationCapturerTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/capture/LocationCapturerTests.kt new file mode 100644 index 0000000000..f9bfa823e7 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/capture/LocationCapturerTests.kt @@ -0,0 +1,145 @@ +package com.onesignal.location.internal.capture + +import android.location.Location +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.location.internal.capture.impl.LocationCapturer +import com.onesignal.location.internal.controller.ILocationController +import com.onesignal.location.internal.preferences.ILocationPreferencesService +import com.onesignal.mocks.MockHelper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class LocationCapturerTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("captureLastLocation will capture current location with fine") { + /* Given */ + val mockLocation = mockk() + every { mockLocation.accuracy } returns 1111F + every { mockLocation.time } returns 2222 + every { mockLocation.latitude } returns 8888.1234567 + every { mockLocation.longitude } returns 9999.1234567 + + val lastLocationTimeSlot = slot() + val mockLocationPreferencesService = mockk() + every { mockLocationPreferencesService.lastLocationTime = capture(lastLocationTimeSlot) } answers { } + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockLocationController = mockk() + every { mockLocationController.subscribe(any()) } just runs + every { mockLocationController.getLastLocation() } returns mockLocation + + val mockApplicationService = MockHelper.applicationService() + every { mockApplicationService.isInForeground } returns true + + val locationCapturer = LocationCapturer( + mockApplicationService, + MockHelper.time(1111), + mockLocationPreferencesService, + mockPropertiesModelStore, + mockLocationController + ) + + /* When */ + locationCapturer.captureLastLocation() + + /* Then */ + mockPropertiesModelStore.model.locationAccuracy shouldBe 1111F + mockPropertiesModelStore.model.locationBackground shouldBe false + mockPropertiesModelStore.model.locationType shouldBe 1 + mockPropertiesModelStore.model.locationLatitude shouldBe 8888.1234567 + mockPropertiesModelStore.model.locationLongitude shouldBe 9999.1234567 + mockPropertiesModelStore.model.locationTimestamp shouldBe 2222 + + lastLocationTimeSlot.captured shouldBe 1111 + } + + test("captureLastLocation will capture current location with coarse") { + /* Given */ + val mockLocation = mockk() + every { mockLocation.accuracy } returns 1111F + every { mockLocation.time } returns 2222 + every { mockLocation.latitude } returns 8888.123456789 + every { mockLocation.longitude } returns 9999.123456789 + + val lastLocationTimeSlot = slot() + val mockLocationPreferencesService = mockk() + every { mockLocationPreferencesService.lastLocationTime = capture(lastLocationTimeSlot) } answers { } + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockLocationController = mockk() + every { mockLocationController.subscribe(any()) } just runs + every { mockLocationController.getLastLocation() } returns mockLocation + + val mockApplicationService = MockHelper.applicationService() + every { mockApplicationService.isInForeground } returns true + + val locationCapturer = LocationCapturer( + mockApplicationService, + MockHelper.time(1111), + mockLocationPreferencesService, + mockPropertiesModelStore, + mockLocationController + ) + + /* When */ + locationCapturer.locationCoarse = true + locationCapturer.captureLastLocation() + + /* Then */ + mockPropertiesModelStore.model.locationAccuracy shouldBe 1111F + mockPropertiesModelStore.model.locationBackground shouldBe false + mockPropertiesModelStore.model.locationType shouldBe 0 + mockPropertiesModelStore.model.locationLatitude shouldBe 8888.1234568 + mockPropertiesModelStore.model.locationLongitude shouldBe 9999.1234568 + mockPropertiesModelStore.model.locationTimestamp shouldBe 2222 + + lastLocationTimeSlot.captured shouldBe 1111 + } + + test("captureLastLocation will not capture current location when not available") { + /* Given */ + val lastLocationTimeSlot = slot() + val mockLocationPreferencesService = mockk() + every { mockLocationPreferencesService.lastLocationTime = capture(lastLocationTimeSlot) } answers { } + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockLocationController = mockk() + every { mockLocationController.subscribe(any()) } just runs + every { mockLocationController.getLastLocation() } returns null + + val mockApplicationService = MockHelper.applicationService() + + val locationCapturer = LocationCapturer( + mockApplicationService, + MockHelper.time(1111), + mockLocationPreferencesService, + mockPropertiesModelStore, + mockLocationController + ) + + /* When */ + locationCapturer.captureLastLocation() + + /* Then */ + mockPropertiesModelStore.model.locationAccuracy shouldBe null + mockPropertiesModelStore.model.locationBackground shouldBe null + mockPropertiesModelStore.model.locationType shouldBe null + mockPropertiesModelStore.model.locationLatitude shouldBe null + mockPropertiesModelStore.model.locationLongitude shouldBe null + mockPropertiesModelStore.model.locationTimestamp shouldBe null + + lastLocationTimeSlot.captured shouldBe 1111 + } +}) diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/controller/GmsLocationControllerTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/controller/GmsLocationControllerTests.kt new file mode 100644 index 0000000000..e40f3444c1 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/controller/GmsLocationControllerTests.kt @@ -0,0 +1,179 @@ +package com.onesignal.location.internal.controller + +import android.location.Location +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.location.internal.controller.impl.GmsLocationController +import com.onesignal.location.shadows.ShadowFusedLocationProviderApi +import com.onesignal.location.shadows.ShadowGoogleApiClient +import com.onesignal.location.shadows.ShadowGoogleApiClientBuilder +import com.onesignal.mocks.AndroidMockHelper +import com.onesignal.notifications.extensions.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.every +import io.mockk.spyk +import io.mockk.verify +import io.mockk.verifySequence +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config( + packageName = "com.onesignal.example", + shadows = [ShadowGoogleApiClientBuilder::class], + sdk = [26] +) +@RobolectricTest +@RunWith(KotestTestRunner::class) +class GmsLocationControllerTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("start will connect and fire locationChanged with location") { + /* Given */ + val location = Location("TEST_PROVIDER") + location.latitude = 123.45 + location.longitude = 678.91 + + ShadowFusedLocationProviderApi.injectToLocationServices(listOf(location)) + val applicationService = AndroidMockHelper.applicationService() + every { applicationService.isInForeground } returns true + val gmsLocationController = GmsLocationController(applicationService) + + val spyLocationUpdateHandler = spyk() + gmsLocationController.subscribe(spyLocationUpdateHandler) + + /* When */ + val response = gmsLocationController.start() + + /* Then */ + response shouldBe true + ShadowGoogleApiClient.connected shouldBe true + verify(exactly = 1) { + spyLocationUpdateHandler.onLocationChanged( + withArg { + it.latitude shouldBe 123.45 + it.longitude shouldBe 678.91 + } + ) + } + } + + test("start twice will return the initial location") { + /* Given */ + val location1 = Location("TEST_PROVIDER") + location1.latitude = 123.45 + location1.longitude = 678.91 + + val location2 = Location("TEST_PROVIDER") + location2.latitude = 678.91 + location2.longitude = 123.45 + + ShadowFusedLocationProviderApi.injectToLocationServices(listOf(location1, location2)) + val applicationService = AndroidMockHelper.applicationService() + every { applicationService.isInForeground } returns true + val gmsLocationController = GmsLocationController(applicationService) + + val spyLocationUpdateHandler = spyk() + gmsLocationController.subscribe(spyLocationUpdateHandler) + + /* When */ + val response1 = gmsLocationController.start() + val response2 = gmsLocationController.start() + + /* Then */ + response1 shouldBe true + response2 shouldBe true + ShadowGoogleApiClient.connected shouldBe true + verifySequence { + spyLocationUpdateHandler.onLocationChanged( + withArg { + it.latitude shouldBe 123.45 + it.longitude shouldBe 678.91 + } + ) + spyLocationUpdateHandler.onLocationChanged( + withArg { + it.latitude shouldBe 123.45 + it.longitude shouldBe 678.91 + } + ) + } + } + + test("getLastLocation will retrieve a new location") { + /* Given */ + val location1 = Location("TEST_PROVIDER") + location1.latitude = 123.45 + location1.longitude = 678.91 + + val location2 = Location("TEST_PROVIDER") + location2.latitude = 678.91 + location2.longitude = 123.45 + + ShadowFusedLocationProviderApi.injectToLocationServices(listOf(location1, location2)) + val applicationService = AndroidMockHelper.applicationService() + every { applicationService.isInForeground } returns true + val gmsLocationController = GmsLocationController(applicationService) + + val spyLocationUpdateHandler = spyk() + gmsLocationController.subscribe(spyLocationUpdateHandler) + + /* When */ + val response = gmsLocationController.start() + val lastLocation = gmsLocationController.getLastLocation() + + /* Then */ + response shouldBe true + lastLocation shouldNotBe null + lastLocation!!.latitude shouldBe 678.91 + lastLocation.longitude shouldBe 123.45 + ShadowGoogleApiClient.connected shouldBe true + verifySequence { + spyLocationUpdateHandler.onLocationChanged( + withArg { + it.latitude shouldBe 123.45 + it.longitude shouldBe 678.91 + } + ) + } + } + + test("stop will disconnect") { + /* Given */ + val location1 = Location("TEST_PROVIDER") + location1.latitude = 123.45 + location1.longitude = 678.91 + + val location2 = Location("TEST_PROVIDER") + location2.latitude = 678.91 + location2.longitude = 123.45 + + ShadowFusedLocationProviderApi.injectToLocationServices(listOf(location1, location2)) + val applicationService = AndroidMockHelper.applicationService() + every { applicationService.isInForeground } returns true + val gmsLocationController = GmsLocationController(applicationService) + + val spyLocationUpdateHandler = spyk() + gmsLocationController.subscribe(spyLocationUpdateHandler) + + /* When */ + val response = gmsLocationController.start() + gmsLocationController.stop() + + /* Then */ + response shouldBe true + ShadowGoogleApiClient.connected shouldBe false + verifySequence { + spyLocationUpdateHandler.onLocationChanged( + withArg { + it.latitude shouldBe 123.45 + it.longitude shouldBe 678.91 + } + ) + } + } +}) diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt new file mode 100644 index 0000000000..20576d6687 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt @@ -0,0 +1,123 @@ +package com.onesignal.location.internal.permissions + +import com.onesignal.core.internal.permissions.IRequestPermissionService +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.AndroidMockHelper +import com.onesignal.notifications.extensions.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.delay +import org.junit.runner.RunWith + +@RobolectricTest +@RunWith(KotestTestRunner::class) +class LocationPermissionControllerTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("prompt will return true once permission is accepted by user") { + /* Given */ + val mockRequestPermissionService = mockk() + + val locationPermissionController = LocationPermissionController( + mockRequestPermissionService, + AndroidMockHelper.applicationService() + ) + + every { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } coAnswers { + delay(1000) + locationPermissionController.onAccept() + } + + /* When */ + val beforeTime = System.currentTimeMillis() + val response = locationPermissionController.prompt(false, "permission") + val afterTime = System.currentTimeMillis() + + val deltaTime = afterTime - beforeTime + + /* Then */ + response shouldBe true + deltaTime shouldBeGreaterThan 1000 + verify(exactly = 1) { mockRequestPermissionService.startPrompt(false, any(), "permission", any()) } + } + + test("prompt will return false once permission is rejected by user") { + /* Given */ + val mockRequestPermissionService = mockk() + + val locationPermissionController = LocationPermissionController( + mockRequestPermissionService, + AndroidMockHelper.applicationService() + ) + + every { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } coAnswers { + delay(1000) + locationPermissionController.onReject(false) + } + + /* When */ + val beforeTime = System.currentTimeMillis() + val response = locationPermissionController.prompt(false, "permission") + val afterTime = System.currentTimeMillis() + + val deltaTime = afterTime - beforeTime + + /* Then */ + response shouldBe false + deltaTime shouldBeGreaterThan 1000 + verify(exactly = 1) { mockRequestPermissionService.startPrompt(false, any(), "permission", any()) } + } + + test("prompt will notify subscribers as accepted once permission is accepted by user") { + /* Given */ + val mockRequestPermissionService = mockk() + + val locationPermissionController = LocationPermissionController( + mockRequestPermissionService, + AndroidMockHelper.applicationService() + ) + + every { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } coAnswers { + locationPermissionController.onAccept() + } + val spyLocationPermissionChangedHandler = spyk() + + /* When */ + locationPermissionController.subscribe(spyLocationPermissionChangedHandler) + locationPermissionController.prompt(false, "permission") + + /* Then */ + verify(exactly = 1) { spyLocationPermissionChangedHandler.onLocationPermissionChanged(true) } + } + + test("prompt will notify subscribers as rejected once permission is rejected by user") { + /* Given */ + val mockRequestPermissionService = mockk() + + val locationPermissionController = LocationPermissionController( + mockRequestPermissionService, + AndroidMockHelper.applicationService() + ) + + every { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } coAnswers { + locationPermissionController.onReject(false) + } + val spyLocationPermissionChangedHandler = spyk() + + /* When */ + locationPermissionController.subscribe(spyLocationPermissionChangedHandler) + locationPermissionController.prompt(false, "permission") + + /* Then */ + verify(exactly = 1) { spyLocationPermissionChangedHandler.onLocationPermissionChanged(false) } + } +}) diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/AndroidMockHelper.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/AndroidMockHelper.kt new file mode 100644 index 0000000000..eff5fcc5b9 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/AndroidMockHelper.kt @@ -0,0 +1,19 @@ +package com.onesignal.mocks + +import androidx.test.core.app.ApplicationProvider +import com.onesignal.core.internal.application.IApplicationService +import io.mockk.every + +/** + * Singleton which provides common mock services when running in an Android environment. + */ +internal object AndroidMockHelper { + + fun applicationService(): IApplicationService { + val mockAppService = MockHelper.applicationService() + + every { mockAppService.appContext } returns ApplicationProvider.getApplicationContext() + + return mockAppService + } +} diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/DatabaseMockHelper.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/DatabaseMockHelper.kt new file mode 100644 index 0000000000..90fcf42ae7 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/DatabaseMockHelper.kt @@ -0,0 +1,48 @@ +package com.onesignal.mocks + +import com.onesignal.core.internal.database.ICursor +import com.onesignal.core.internal.database.IDatabase +import com.onesignal.core.internal.database.IDatabaseProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk + +/** + * Singleton which provides common mock services. + */ +internal object DatabaseMockHelper { + fun databaseProvider(tableName: String, records: List>? = null): Pair { + val mockOneSignalDatabase = spyk() + + if (records != null) { + val mockCursor = cursor(records!!) + every { + mockOneSignalDatabase.query(tableName, any(), any(), any(), any(), any(), any(), any(), any()) + } answers { + lastArg<(ICursor) -> Unit>().invoke(mockCursor) + } + } + + val mockDatabaseProvider = mockk() + every { mockDatabaseProvider.os } returns mockOneSignalDatabase + + return Pair(mockDatabaseProvider, mockOneSignalDatabase) + } + + fun cursor(records: List>): ICursor { + val mockCursor = mockk() + var index = 0 + every { mockCursor.count } returns records.count() + every { mockCursor.moveToFirst() } answers { index = 0; true } + every { mockCursor.moveToNext() } answers { index++; index < records.count() } + every { mockCursor.getString(any()) } answers { records[index][firstArg()] as String } + every { mockCursor.getFloat(any()) } answers { records[index][firstArg()] as Float } + every { mockCursor.getLong(any()) } answers { records[index][firstArg()] as Long } + every { mockCursor.getInt(any()) } answers { records[index][firstArg()] as Int } + every { mockCursor.getOptString(any()) } answers { records[index][firstArg()] as String? } + every { mockCursor.getOptFloat(any()) } answers { records[index][firstArg()] as Float? } + every { mockCursor.getOptLong(any()) } answers { records[index][firstArg()] as Long? } + every { mockCursor.getOptInt(any()) } answers { records[index][firstArg()] as Int? } + return mockCursor + } +} diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/MockHelper.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/MockHelper.kt new file mode 100644 index 0000000000..aa65195613 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/mocks/MockHelper.kt @@ -0,0 +1,119 @@ +package com.onesignal.mocks + +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IDeviceService +import com.onesignal.core.internal.language.ILanguageContext +import com.onesignal.core.internal.time.ITime +import com.onesignal.session.internal.session.SessionModel +import com.onesignal.session.internal.session.SessionModelStore +import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.properties.PropertiesModel +import com.onesignal.user.internal.properties.PropertiesModelStore +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import java.util.UUID + +/** + * Singleton which provides common mock services. + */ +object MockHelper { + fun time(time: Long): ITime { + val mockTime = mockk() + every { mockTime.currentTimeMillis } returns time + + return mockTime + } + + fun applicationService(): IApplicationService { + val mockAppService = mockk() + + every { mockAppService.addApplicationLifecycleHandler(any()) } just Runs + every { mockAppService.removeApplicationLifecycleHandler(any()) } just Runs + + return mockAppService + } + + const val DEFAULT_APP_ID = "appId" + fun configModelStore(action: ((ConfigModel) -> Unit)? = null): ConfigModelStore { + val configModel = ConfigModel() + + configModel.appId = DEFAULT_APP_ID + + if (action != null) { + action(configModel) + } + + val mockConfigStore = mockk() + + every { mockConfigStore.model } returns configModel + + return mockConfigStore + } + + fun identityModelStore(action: ((IdentityModel) -> Unit)? = null): IdentityModelStore { + val identityModel = IdentityModel() + + identityModel.id = "-singleton" + identityModel.onesignalId = UUID.randomUUID().toString() + + if (action != null) { + action(identityModel) + } + + val mockIdentityStore = mockk() + + every { mockIdentityStore.model } returns identityModel + + return mockIdentityStore + } + + fun propertiesModelStore(action: ((PropertiesModel) -> Unit)? = null): PropertiesModelStore { + val propertiesModel = PropertiesModel() + + propertiesModel.id = "-singleton" + propertiesModel.onesignalId = UUID.randomUUID().toString() + + if (action != null) { + action(propertiesModel) + } + + val mockPropertiesStore = mockk() + + every { mockPropertiesStore.model } returns propertiesModel + + return mockPropertiesStore + } + + fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { + val sessionModel = SessionModel() + + if (action != null) { + action(sessionModel) + } + + val mockSessionStore = mockk() + + every { mockSessionStore.model } returns sessionModel + + return mockSessionStore + } + + fun languageContext(language: String = "en"): ILanguageContext { + val mockLanguageContext = mockk() + + every { mockLanguageContext.language } returns language + + return mockLanguageContext + } + + fun deviceService(): IDeviceService { + val deviceService = mockk() + every { deviceService.deviceType } returns IDeviceService.DeviceType.Android + return deviceService + } +} diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowFusedLocationProviderApi.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowFusedLocationProviderApi.kt new file mode 100644 index 0000000000..252bbe7148 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowFusedLocationProviderApi.kt @@ -0,0 +1,104 @@ +/** + * Modified MIT License + * + * Copyright 2017 OneSignal + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * 1. The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * 2. All copies of substantial portions of the Software may only be used in connection + * with services provided by OneSignal. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.onesignal.location.shadows + +import android.app.PendingIntent +import android.location.Location +import android.os.Looper +import com.google.android.gms.common.api.GoogleApiClient +import com.google.android.gms.common.api.PendingResult +import com.google.android.gms.common.api.ResultCallback +import com.google.android.gms.common.api.Status +import com.google.android.gms.location.FusedLocationProviderApi +import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationListener +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.util.concurrent.TimeUnit + +class ShadowFusedLocationProviderApi : FusedLocationProviderApi { + + val pendingResult = object : PendingResult() { + override fun await(): Status = Status.RESULT_SUCCESS + override fun await(p0: Long, p1: TimeUnit): Status = Status.RESULT_SUCCESS + override fun cancel() { } + override fun isCanceled(): Boolean = false + override fun setResultCallback(p0: ResultCallback) { } + override fun setResultCallback(p0: ResultCallback, p1: Long, p2: TimeUnit) {} + } + override fun getLastLocation(p0: GoogleApiClient): Location { + if (locations != null) { + val location = locations!![index] + index++ + if (index >= locations!!.count()) { + index = 0 + } + + return location + } + + val location = Location("TEST_PROVIDER") + location.latitude = 123.45 + location.longitude = 678.91 + return location + } + + override fun getLocationAvailability(p0: GoogleApiClient): LocationAvailability = throw Exception() + override fun requestLocationUpdates(p0: GoogleApiClient, p1: LocationRequest, p2: LocationListener): PendingResult = pendingResult + override fun requestLocationUpdates(p0: GoogleApiClient, p1: LocationRequest, p2: LocationListener, p3: Looper): PendingResult = pendingResult + override fun requestLocationUpdates(p0: GoogleApiClient, p1: LocationRequest, p2: LocationCallback, p3: Looper): PendingResult = pendingResult + override fun requestLocationUpdates(p0: GoogleApiClient, p1: LocationRequest, p2: PendingIntent): PendingResult = pendingResult + override fun removeLocationUpdates(p0: GoogleApiClient, p1: LocationListener): PendingResult = pendingResult + override fun removeLocationUpdates(p0: GoogleApiClient, p1: PendingIntent): PendingResult = pendingResult + override fun removeLocationUpdates(p0: GoogleApiClient, p1: LocationCallback): PendingResult = pendingResult + override fun setMockMode(p0: GoogleApiClient, p1: Boolean): PendingResult = pendingResult + override fun setMockLocation(p0: GoogleApiClient, p1: Location): PendingResult = pendingResult + override fun flushLocations(p0: GoogleApiClient): PendingResult = pendingResult + + companion object { + private var locations: List? = null + private var index: Int = 0 + fun injectToLocationServices(locations: List) { + this.index = 0 + this.locations = locations + val currentFused = LocationServices.FusedLocationApi + val newFused = ShadowFusedLocationProviderApi() + + val field = LocationServices::class.java.getDeclaredField("FusedLocationApi") + field.isAccessible = true + + val modifiersField: Field = Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + + field.set(null, newFused) + } + } +} diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt new file mode 100644 index 0000000000..855b1531e3 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt @@ -0,0 +1,129 @@ +/** + * Modified MIT License + * + * Copyright 2017 OneSignal + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * 1. The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * 2. All copies of substantial portions of the Software may only be used in connection + * with services provided by OneSignal. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.onesignal.location.shadows + +import android.content.Intent +import android.os.IBinder +import androidx.fragment.app.FragmentActivity +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.Feature +import com.google.android.gms.common.api.Api +import com.google.android.gms.common.api.GoogleApiClient +import com.google.android.gms.common.api.PendingResult +import com.google.android.gms.common.api.ResultCallback +import com.google.android.gms.common.api.Scope +import com.google.android.gms.common.api.Status +import com.google.android.gms.common.internal.BaseGmsClient +import com.google.android.gms.common.internal.IAccountAccessor +import io.mockk.InternalPlatformDsl.toArray +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import java.io.FileDescriptor +import java.io.PrintWriter +import java.util.concurrent.TimeUnit + +@Implements(value = GoogleApiClient.Builder::class, looseSignatures = true) +class ShadowGoogleApiClientBuilder { + @Implementation + fun build(): GoogleApiClient? { + return ShadowGoogleApiClient() + } +} + +class ShadowGoogleApiClient : GoogleApiClient() { + override fun hasConnectedApi(p0: Api<*>): Boolean = connected + override fun getConnectionResult(p0: Api<*>): ConnectionResult = ConnectionResult(0) + override fun connect() { + connected = true + } + + override fun getClient(p0: Api.AnyClientKey): C { + return object : Api.Client { + override fun connect(p0: BaseGmsClient.ConnectionProgressReportCallbacks) { } + override fun disconnect(p0: String) {} + override fun disconnect() { } + override fun isConnected(): Boolean = true + override fun isConnecting(): Boolean = false + override fun getRemoteService(p0: IAccountAccessor?, p1: MutableSet?) {} + override fun requiresSignIn(): Boolean = false + override fun onUserSignOut(p0: BaseGmsClient.SignOutCallbacks) { } + override fun requiresAccount(): Boolean = false + override fun requiresGooglePlayServices(): Boolean = false + override fun providesSignIn(): Boolean = false + override fun getSignInIntent(): Intent = Intent() + override fun dump(p0: String, p1: FileDescriptor?, p2: PrintWriter, p3: Array?) { } + override fun getServiceBrokerBinder(): IBinder? = null + override fun getRequiredFeatures(): Array = listOf().toArray() as Array + override fun getEndpointPackageName(): String = "" + override fun getMinApkVersion(): Int = 0 + override fun getAvailableFeatures(): Array = listOf().toArray() as Array + override fun getScopesForConnectionlessNonSignIn(): MutableSet = mutableSetOf() + override fun getLastDisconnectMessage(): String? = null + } as C + } + + override fun blockingConnect(): ConnectionResult { + connected = true + return ConnectionResult(0) + } + override fun blockingConnect(p0: Long, p1: TimeUnit): ConnectionResult { + connected = true + return ConnectionResult(0) + } + override fun disconnect() { + connected = false + } + override fun reconnect() { + connected = true + } + override fun clearDefaultAccountAndReconnect(): PendingResult { + return object : PendingResult() { + override fun await(): Status = Status.RESULT_SUCCESS + override fun await(p0: Long, p1: TimeUnit): Status = Status.RESULT_SUCCESS + override fun cancel() { } + override fun isCanceled(): Boolean = false + override fun setResultCallback(p0: ResultCallback) { } + override fun setResultCallback(p0: ResultCallback, p1: Long, p2: TimeUnit) { + } + } + } + + override fun stopAutoManage(p0: FragmentActivity) { } + override fun isConnected(): Boolean = connected + override fun isConnecting(): Boolean = false + override fun registerConnectionCallbacks(p0: ConnectionCallbacks) { } + override fun isConnectionCallbacksRegistered(p0: ConnectionCallbacks): Boolean = true + override fun unregisterConnectionCallbacks(p0: ConnectionCallbacks) { } + override fun registerConnectionFailedListener(p0: OnConnectionFailedListener) { } + override fun isConnectionFailedListenerRegistered(p0: OnConnectionFailedListener): Boolean = false + override fun unregisterConnectionFailedListener(p0: OnConnectionFailedListener) { } + override fun dump(p0: String, p1: FileDescriptor, p2: PrintWriter, p3: Array) { } + + companion object { + var connected: Boolean = false + } +}