diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..f49196a --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,188 @@ +name: Android CI + +on: + push: + branches: [ '**' ] + pull_request: + branches: [ '**' ] + workflow_dispatch: + +jobs: + compile: + runs-on: ubuntu-latest + name: "Compile all sources" + + steps: + - name: Checkout project + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + cache: gradle + + - name: Load build outputs + uses: actions/cache@v4 + with: + path: build + key: build-${{ github.sha }} + + - name: Create properties file with empty API key + run: echo mapsApiKey="\"${{ secrets.mapsApiKey }}\"" >> local.properties + + - name: Build App + run: ./gradlew assemble --stacktrace + + - name: Build unit tests + run: ./gradlew assembleDebugUnitTest assembleReleaseUnitTest --stacktrace + + - name: Build instrumentation tests + run: ./gradlew assembleAndroidTest --stacktrace + + unit-test: + name: "Run all unit tests" + needs: compile + runs-on: ubuntu-latest + + steps: + - name: Checkout project + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + cache: gradle + + - name: Load build outputs + uses: actions/cache@v4 + with: + path: build + key: build-${{ github.sha }} + + - name: Run Unit Tests + run: ./gradlew test --stacktrace + + - name: Run Linter + run: ./gradlew lint --stacktrace + continue-on-error: true + + - name: Upload reports + uses: actions/upload-artifact@v4 + with: + name: Unit Test Reports + path: build/reports + if: failure() + + instrumentation: + name: "Testing on API ${{ matrix.api-level }} for ${{ matrix.target }}" + needs: compile + # macOS provided hardware-accelerated emulator + # but now so does Linux + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + api-level: [ 15, 21, 24, 31 ] + target: [ default, google_apis, google_apis_playstore ] + exclude: + - api-level: 15 + target: google_apis_playstore + - api-level: 21 + target: google_apis_playstore + - api-level: 24 + target: google_apis + - api-level: 31 + target: google_apis + + steps: + - name: Checkout project + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + cache: gradle + + - name: Load build outputs + uses: actions/cache@v4 + with: + path: build + key: build-${{ github.sha }} + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }}-${{ matrix.target }}-sd + + - name: Set up JRE 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + java-package: 'jre' + #cache: gradle + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: ${{ matrix.api-level >= 30 && 'x86_64' || 'x86' }} + force-avd-creation: false + sdcard-path-or-size: '64M' + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run Instrumented Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: ${{ matrix.api-level >= 30 && 'x86_64' || 'x86' }} + profile: Nexus 6 + force-avd-creation: false + sdcard-path-or-size: '64M' + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: adb logcat -c && adb logcat -f /sdcard/logcat.txt & JAVA_HOME="${JAVA_HOME_11_X64}" ./gradlew connectedCheck --stacktrace || ( adb pull /sdcard/logcat.txt build/reports/; exit 1 ) + + - name: Upload reports + uses: actions/upload-artifact@v4 + with: + name: Instrument Test Reports API ${{ matrix.api-level }} ${{ matrix.target }} + path: build/reports + if: failure() + + - name: Save successful debug APK + uses: actions/upload-artifact@v4 + with: + name: Debug APK + path: build/outputs/apk/debug/aprsdroid-debug.apk + if: matrix.api-level == 31 && matrix.target == 'google_apis' diff --git a/README.md b/README.md index 8badb97..a5e59e6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ messages. APRSdroid is Open Source Software written in Scala and licensed under the GPLv2. +master: [![Android CI](../../actions/workflows/android.yml/badge.svg?branch=master&event=push)](../../actions/workflows/android.yml) + Quick links: - [Google Play](https://play.google.com/store/apps/details?id=org.aprsdroid.app) diff --git a/androidTest/java/org/aprsdroid/app/CoordinateTest.java b/androidTest/java/org/aprsdroid/app/CoordinateTest.java new file mode 100644 index 0000000..415b91e --- /dev/null +++ b/androidTest/java/org/aprsdroid/app/CoordinateTest.java @@ -0,0 +1,57 @@ +package org.aprsdroid.app; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; + +import org.aprsdroid.app.testing.CoordinateMatcher; +import org.junit.Test; + +import scala.Tuple2; + +public class CoordinateTest { + // Reference data generated from https://www.pgc.umn.edu/apps/convert/ + private static final String providedNLatitude = "77° 15' 30\" N"; + private static final float expectedNLatitude = 77.258333f; + private static final String providedELongitude = "164° 45' 15\" E"; + private static final float expectedELongitude = 164.754167f; + private static final String providedSLatitude = "45° 30' 45\" S"; + private static final float expectedSLatitude = -45.5125f; + private static final String providedWLongitude = "97° 20' 40\" W"; + private static final float expectedWLongitude = -97.344444f; + + @Test + public void givenLocationInNEHemisphere_whenFormattedAsDMSString_thenParseBackIntoDecimalValue() { + Tuple2 actual = AprsPacket$.MODULE$.formatCoordinates(expectedNLatitude, expectedELongitude); + float floatLatitude = CoordinateMatcher.matchLatitude(actual._1); + float floatLongitude = CoordinateMatcher.matchLongitude(actual._2); + assertThat("Latitude", (double) floatLatitude, closeTo((double) expectedNLatitude, 1e-7)); + assertThat("Longitude", (double) floatLongitude, closeTo((double) expectedELongitude, 1e-7)); + } + + @Test + public void givenLocationInNWHemisphere_whenFormattedAsDMSString_thenParseBackIntoDecimalValue() { + Tuple2 actual = AprsPacket$.MODULE$.formatCoordinates(expectedNLatitude, expectedWLongitude); + float floatLatitude = CoordinateMatcher.matchLatitude(actual._1); + float floatLongitude = CoordinateMatcher.matchLongitude(actual._2); + assertThat("Latitude", (double) floatLatitude, closeTo((double) expectedNLatitude, 1e-7)); + assertThat("Longitude", (double) floatLongitude, closeTo((double) expectedWLongitude, 1e-7)); + } + + @Test + public void givenLocationInSEHemisphere_whenFormattedAsDMSString_thenParseBackIntoDecimalValue() { + Tuple2 actual = AprsPacket$.MODULE$.formatCoordinates(expectedSLatitude, expectedELongitude); + float floatLatitude = CoordinateMatcher.matchLatitude(actual._1); + float floatLongitude = CoordinateMatcher.matchLongitude(actual._2); + assertThat("Latitude", (double) floatLatitude, closeTo((double) expectedSLatitude, 1e-7)); + assertThat("Longitude", (double) floatLongitude, closeTo((double) expectedELongitude, 1e-7)); + } + + @Test + public void givenLocationInSWHemisphere_whenFormattedAsDMSString_thenParseBackIntoDecimalValue() { + Tuple2 actual = AprsPacket$.MODULE$.formatCoordinates(expectedSLatitude, expectedWLongitude); + float floatLatitude = CoordinateMatcher.matchLatitude(actual._1); + float floatLongitude = CoordinateMatcher.matchLongitude(actual._2); + assertThat("Latitude", (double) floatLatitude, closeTo((double) expectedSLatitude, 1e-7)); + assertThat("Longitude", (double) floatLongitude, closeTo((double) expectedWLongitude, 1e-7)); + } +} \ No newline at end of file diff --git a/androidTest/java/org/aprsdroid/app/ExampleInstrumentedTest.java b/androidTest/java/org/aprsdroid/app/ExampleInstrumentedTest.java index 85369e0..67e0135 100644 --- a/androidTest/java/org/aprsdroid/app/ExampleInstrumentedTest.java +++ b/androidTest/java/org/aprsdroid/app/ExampleInstrumentedTest.java @@ -2,8 +2,8 @@ package org.aprsdroid.app; import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/androidTest/java/org/aprsdroid/app/FirstRunDialog.java b/androidTest/java/org/aprsdroid/app/FirstRunDialog.java new file mode 100644 index 0000000..7f0035b --- /dev/null +++ b/androidTest/java/org/aprsdroid/app/FirstRunDialog.java @@ -0,0 +1,112 @@ +package org.aprsdroid.app; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.core.AllOf.allOf; +import static org.hamcrest.core.StringContains.containsString; + +import android.content.SharedPreferences; + +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.aprsdroid.app.testing.SharedPreferencesRule; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class FirstRunDialog { + private final String pref_callsign = "callsign"; + private final String pref_passcode = "passcode"; + public ActivityScenarioRule activityRule = new ActivityScenarioRule<>(LogActivity.class); + public SharedPreferencesRule prefsRule = new SharedPreferencesRule() { + @Override + protected void modifyPreferences(SharedPreferences preferences) { + preferences.edit().clear().commit(); + } + }; + @Rule + public RuleChain rules = RuleChain.outerRule(prefsRule).around(activityRule); + + @Test + public void givenAFirstTimeRun_whenProvidedABadPasscode_ThenDialogStaysOpen() { + onView(isRoot()) + .inRoot(isDialog()) + .check(matches(allOf( + hasDescendant(withText(containsString("Welcome to APRSdroid"))), + hasDescendant(withId(R.id.callsign)), + hasDescendant(withId(R.id.passcode))))); + onView(withId(R.id.callsign)) + .check(matches(withHint(containsString("Callsign")))) + .perform(typeText("XA1AAA"), closeSoftKeyboard()); + onView(withId(R.id.passcode)) + .check(matches(withHint(containsString("Passcode")))) + .perform(typeText("12345"), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); // OK Button + onView(isRoot()) + .inRoot(isDialog()) + .check(matches(allOf( + hasDescendant(withText(containsString("Welcome to APRSdroid"))), + hasDescendant(withId(R.id.callsign)), + hasDescendant(withId(R.id.passcode))))); + try { + Thread.sleep(5000); + } catch (InterruptedException ex) { + } + Assert.assertTrue(true); + } + + @Test + public void givenAFirstTimeRun_whenProvidedAGoodPasscode_ThenDialogCloses() { + onView(isRoot()) + .inRoot(isDialog()) + .check(matches(allOf( + hasDescendant(withText(containsString("Welcome to APRSdroid"))), + hasDescendant(withId(R.id.callsign)), + hasDescendant(withId(R.id.passcode))))); + onView(withId(R.id.callsign)) + .check(matches(withHint(containsString("Callsign")))) + .perform(typeText("XA1AAA"), closeSoftKeyboard()); + onView(withId(R.id.passcode)) + .check(matches(withHint(containsString("Passcode")))) + .perform(typeText("23459"), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); // OK Button + onView(allOf( + isRoot(), + hasDescendant(withText(containsString("Welcome to APRSdroid"))), + hasDescendant(withId(R.id.callsign)), + hasDescendant(withId(R.id.passcode)))) + .check(doesNotExist()); + } + + @Test + public void givenAFirstTimeRun_whenProvidedAGoodPasscode_ThenPrefsSaved() { + String expected_callsign = "XA1AAA"; + String expected_passcode = "23459"; + SharedPreferences prefs = prefsRule.getPreferences(); + Assert.assertNull("Callsign", prefs.getString(pref_callsign, null)); + Assert.assertNull("Passcode", prefs.getString(pref_passcode, null)); + onView(withId(R.id.callsign)) + .check(matches(withHint(containsString("Callsign")))) + .perform(typeText(expected_callsign), closeSoftKeyboard()); + onView(withId(R.id.passcode)) + .check(matches(withHint(containsString("Passcode")))) + .perform(typeText(expected_passcode), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); // OK Button + Assert.assertEquals("Callsign", expected_callsign, prefs.getString(pref_callsign, null)); + Assert.assertEquals("Passcode", expected_passcode, prefs.getString(pref_passcode, null)); + } +} diff --git a/androidTest/java/org/aprsdroid/app/MapModeTest.java b/androidTest/java/org/aprsdroid/app/MapModeTest.java new file mode 100644 index 0000000..6f19c7d --- /dev/null +++ b/androidTest/java/org/aprsdroid/app/MapModeTest.java @@ -0,0 +1,464 @@ +package org.aprsdroid.app; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.test.espresso.action.GeneralLocation; +import androidx.test.espresso.action.GeneralSwipeAction; +import androidx.test.espresso.action.Press; +import androidx.test.espresso.action.Swipe; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.FlakyTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +import org.aprsdroid.app.testing.DMSLocationAssertion; +import org.aprsdroid.app.testing.SharedPreferencesRule; +import org.aprsdroid.app.testing.SpecificDMSLocationAssertion; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.rules.RuleChain; +import org.junit.runner.RunWith; + +@RunWith(Enclosed.class) +public class MapModeTest { + private static final String TAG = "APRSdroid-MapModeTest"; + private static final Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + private static final ActivityScenarioRule activityRule = + new ActivityScenarioRule<>(new Intent(appContext, GoogleMapAct.class) + .putExtra("info", R.string.p_source_from_map_save)); + + @RunWith(AndroidJUnit4.class) + public static class GivenDefaultHomeLocation { + private final SharedPreferencesRule prefsRule = new SharedPreferencesRule() { + @Override + protected void modifyPreferences(SharedPreferences preferences) { + preferences.edit().clear().commit(); + } + }; + + @Rule + public final RuleChain rules = RuleChain.outerRule(prefsRule).around(activityRule); + + @Test + public void whenFirstLoaded_thenSaveDisabled() { + onView(withId(R.id.info)) + .check(matches(withText(""))); + onView(withId(R.id.accept)) + .check(matches(not(isEnabled()))); + } + + @Test + public void whenMapIsDragged_thenPositionAndButtonShown() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.info)) + .check(matches(not(withText("")))); + onView(withId(R.id.accept)) + .check(matches(isEnabled())); + } + + @Test + public void whenMapIsDragged_thenPositionIsValidCoordinates() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.info)) + .check(new DMSLocationAssertion()); + } + } + + @RunWith(AndroidJUnit4.class) + public static class GivenSavedLocationInNEHemisphere { + private static final float expectedLatitude = 37.50123f; + private static final float expectedLongitude = 88.25034f; + private static final float expectedZoom = 4.0f; + + private final SharedPreferencesRule prefsRule = new SharedPreferencesRule() { + @Override + protected void modifyPreferences(SharedPreferences preferences) { + preferences.edit() + .putFloat("map_lat", expectedLatitude) + .putFloat("map_lon", expectedLongitude) + .putFloat("map_zoom", expectedZoom) + .commit(); + } + }; + + @Rule + public final RuleChain rules = RuleChain.outerRule(prefsRule).around(activityRule); + + @Test + public void whenFirstLoaded_thenSaveDisabled() { + onView(withId(R.id.info)) + .check(matches(withText(""))); + onView(withId(R.id.accept)) + .check(matches(not(isEnabled()))); + } + + @Test + public void whenMapIsDragged_thenPositionAndButtonShown() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.info)) + .check(matches(not(withText("")))); + onView(withId(R.id.accept)) + .check(matches(isEnabled())); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForth_thenPositionIsOriginalCoordinates() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.info)) + .check(new SpecificDMSLocationAssertion(expectedLatitude, expectedLongitude)); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForthAndSaved_thenPositionIsSavedCorrectly() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + try { + Thread.sleep(500); + } catch (InterruptedException ex) { + Log.w(TAG, "Sleep was interrupted: " + ex); + } + prefsRule.getPreferences() + .edit() + .remove("map_lat") + .remove("map_lon") + .remove("map_zoom") + .commit(); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.accept)) + .perform(click()); + float actualLatitude = prefsRule.getPreferences().getFloat("map_lat", 0.0f); + float actualLongitude = prefsRule.getPreferences().getFloat("map_lon", 0.0f); + float actualZoom = prefsRule.getPreferences().getFloat("map_zoom", 0.0f); + assertThat("Latitude", (double) actualLatitude, closeTo(expectedLatitude, 5e-2)); + assertThat("Longitude", (double) actualLongitude, closeTo(expectedLongitude, 5e-2)); + assertThat("Zoom", (double) actualZoom, closeTo(expectedZoom, 1e-7)); + } + } + + @RunWith(AndroidJUnit4.class) + public static class GivenSavedLocationInSEHemisphere { + private static final float expectedLatitude = -37.50123f; + private static final float expectedLongitude = 88.25034f; + private static final float expectedZoom = 4.5f; + + private final SharedPreferencesRule prefsRule = new SharedPreferencesRule() { + @Override + protected void modifyPreferences(SharedPreferences preferences) { + preferences.edit() + .putFloat("map_lat", expectedLatitude) + .putFloat("map_lon", expectedLongitude) + .putFloat("map_zoom", expectedZoom) + .commit(); + } + }; + + @Rule + public final RuleChain rules = RuleChain.outerRule(prefsRule).around(activityRule); + + @Test + public void whenFirstLoaded_thenSaveDisabled() { + onView(withId(R.id.info)) + .check(matches(withText(""))); + onView(withId(R.id.accept)) + .check(matches(not(isEnabled()))); + } + + @Test + public void whenMapIsDragged_thenPositionAndButtonShown() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.info)) + .check(matches(not(withText("")))); + onView(withId(R.id.accept)) + .check(matches(isEnabled())); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForth_thenPositionIsOriginalCoordinates() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.info)) + .check(new SpecificDMSLocationAssertion(expectedLatitude, expectedLongitude)); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForthAndSaved_thenPositionIsSavedCorrectly() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + try { + Thread.sleep(500); + } catch (InterruptedException ex) { + Log.w(TAG, "Sleep was interrupted: " + ex); + } + prefsRule.getPreferences() + .edit() + .remove("map_lat") + .remove("map_lon") + .remove("map_zoom") + .commit(); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.accept)) + .perform(click()); + float actualLatitude = prefsRule.getPreferences().getFloat("map_lat", 0.0f); + float actualLongitude = prefsRule.getPreferences().getFloat("map_lon", 0.0f); + float actualZoom = prefsRule.getPreferences().getFloat("map_zoom", 0.0f); + assertThat("Latitude", (double) actualLatitude, closeTo(expectedLatitude, 5e-2)); + assertThat("Longitude", (double) actualLongitude, closeTo(expectedLongitude, 5e-2)); + assertThat("Zoom", (double) actualZoom, closeTo(expectedZoom, 1e-7)); + } + } + + @RunWith(AndroidJUnit4.class) + public static class GivenSavedLocationInNWHemisphere { + private static final float expectedLatitude = 37.50123f; + private static final float expectedLongitude = -88.25034f; + private static final float expectedZoom = 5.0f; + + private final SharedPreferencesRule prefsRule = new SharedPreferencesRule() { + @Override + protected void modifyPreferences(SharedPreferences preferences) { + preferences.edit() + .putFloat("map_lat", expectedLatitude) + .putFloat("map_lon", expectedLongitude) + .putFloat("map_zoom", expectedZoom) + .commit(); + } + }; + + @Rule + public final RuleChain rules = RuleChain.outerRule(prefsRule).around(activityRule); + + @Test + public void whenFirstLoaded_thenSaveDisabled() { + onView(withId(R.id.info)) + .check(matches(withText(""))); + onView(withId(R.id.accept)) + .check(matches(not(isEnabled()))); + } + + @Test + public void whenMapIsDragged_thenPositionAndButtonShown() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.info)) + .check(matches(not(withText("")))); + onView(withId(R.id.accept)) + .check(matches(isEnabled())); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForth_thenPositionIsOriginalCoordinates() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.info)) + .check(new SpecificDMSLocationAssertion(expectedLatitude, expectedLongitude)); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForthAndSaved_thenPositionIsSavedCorrectly() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + try { + Thread.sleep(500); + } catch (InterruptedException ex) { + Log.w(TAG, "Sleep was interrupted: " + ex); + } + prefsRule.getPreferences() + .edit() + .remove("map_lat") + .remove("map_lon") + .remove("map_zoom") + .commit(); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.accept)) + .perform(click()); + float actualLatitude = prefsRule.getPreferences().getFloat("map_lat", 0.0f); + float actualLongitude = prefsRule.getPreferences().getFloat("map_lon", 0.0f); + float actualZoom = prefsRule.getPreferences().getFloat("map_zoom", 0.0f); + assertThat("Latitude", (double) actualLatitude, closeTo(expectedLatitude, 5e-2)); + assertThat("Longitude", (double) actualLongitude, closeTo(expectedLongitude, 5e-2)); + assertThat("Zoom", (double) actualZoom, closeTo(expectedZoom, 1e-7)); + } + } + + @RunWith(AndroidJUnit4.class) + public static class GivenSavedLocationInSWHemisphere { + private static final float expectedLatitude = -37.50123f; + private static final float expectedLongitude = -88.25034f; + private static final float expectedZoom = 5.5f; + + private final SharedPreferencesRule prefsRule = new SharedPreferencesRule() { + @Override + protected void modifyPreferences(SharedPreferences preferences) { + preferences.edit() + .putFloat("map_lat", expectedLatitude) + .putFloat("map_lon", expectedLongitude) + .putFloat("map_zoom", expectedZoom) + .commit(); + } + }; + + @Rule + public final RuleChain rules = RuleChain.outerRule(prefsRule).around(activityRule); + + @Test + public void whenFirstLoaded_thenSaveDisabled() { + onView(withId(R.id.info)) + .check(matches(withText(""))); + onView(withId(R.id.accept)) + .check(matches(not(isEnabled()))); + } + + @Test + public void whenMapIsDragged_thenPositionAndButtonShown() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.info)) + .check(matches(not(withText("")))); + onView(withId(R.id.accept)) + .check(matches(isEnabled())); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForth_thenPositionIsOriginalCoordinates() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.info)) + .check(new SpecificDMSLocationAssertion(expectedLatitude, expectedLongitude)); + } + + @Test + @FlakyTest + public void whenMapIsDraggedBackAndForthAndSaved_thenPositionIsSavedCorrectly() { + Assume.assumeThat("Google Play Services requires", + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext), + equalTo(ConnectionResult.SUCCESS)); + try { + Thread.sleep(500); + } catch (InterruptedException ex) { + Log.w(TAG, "Sleep was interrupted: " + ex); + } + prefsRule.getPreferences() + .edit() + .remove("map_lat") + .remove("map_lon") + .remove("map_zoom") + .commit(); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER, + GeneralLocation.CENTER_LEFT, Press.THUMB)); + onView(withId(R.id.mapview)) + .perform(new GeneralSwipeAction(Swipe.SLOW, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER, Press.THUMB)); + onView(withId(R.id.accept)) + .perform(click()); + float actualLatitude = prefsRule.getPreferences().getFloat("map_lat", 0.0f); + float actualLongitude = prefsRule.getPreferences().getFloat("map_lon", 0.0f); + float actualZoom = prefsRule.getPreferences().getFloat("map_zoom", 0.0f); + assertThat("Latitude", (double) actualLatitude, closeTo(expectedLatitude, 5e-2)); + assertThat("Longitude", (double) actualLongitude, closeTo(expectedLongitude, 5e-2)); + assertThat("Zoom", (double) actualZoom, closeTo(expectedZoom, 1e-7)); + } + } +} diff --git a/androidTest/java/org/aprsdroid/app/testing/DMSLocationAssertion.java b/androidTest/java/org/aprsdroid/app/testing/DMSLocationAssertion.java new file mode 100644 index 0000000..c3161f0 --- /dev/null +++ b/androidTest/java/org/aprsdroid/app/testing/DMSLocationAssertion.java @@ -0,0 +1,27 @@ +package org.aprsdroid.app.testing; + +import static org.hamcrest.Matchers.instanceOf; + +import android.view.View; +import android.widget.TextView; + +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.ViewAssertion; + +import org.junit.Assert; + +public class DMSLocationAssertion implements ViewAssertion { + protected void checkCoordinates(float latitude, float longitude) { + } + + @Override + public void check(View view, NoMatchingViewException noViewFoundException) { + if (view == null) + throw noViewFoundException; + Assert.assertThat(view, instanceOf(TextView.class)); + TextView text = (TextView) view; + float latitude = CoordinateMatcher.matchLatitude(text.getText()); + float longitude = CoordinateMatcher.matchLongitude(text.getText()); + checkCoordinates(latitude, longitude); + } +} diff --git a/androidTest/java/org/aprsdroid/app/testing/SharedPreferencesRule.java b/androidTest/java/org/aprsdroid/app/testing/SharedPreferencesRule.java new file mode 100644 index 0000000..afa96d4 --- /dev/null +++ b/androidTest/java/org/aprsdroid/app/testing/SharedPreferencesRule.java @@ -0,0 +1,29 @@ +package org.aprsdroid.app.testing; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +public abstract class SharedPreferencesRule implements TestRule { + private SharedPreferences preferences; + + protected abstract void modifyPreferences(SharedPreferences preferences); + + public SharedPreferences getPreferences() { + return preferences; + } + + @Override + public Statement apply(Statement base, Description description) { + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + preferences = PreferenceManager.getDefaultSharedPreferences(appContext); + modifyPreferences(preferences); + return base; + } +} diff --git a/androidTest/java/org/aprsdroid/app/testing/SpecificDMSLocationAssertion.java b/androidTest/java/org/aprsdroid/app/testing/SpecificDMSLocationAssertion.java new file mode 100644 index 0000000..28944ea --- /dev/null +++ b/androidTest/java/org/aprsdroid/app/testing/SpecificDMSLocationAssertion.java @@ -0,0 +1,22 @@ +package org.aprsdroid.app.testing; + +import static org.hamcrest.Matchers.closeTo; + +import org.junit.Assert; + +public class SpecificDMSLocationAssertion extends DMSLocationAssertion { + private final float expectedLatitude; + private final float expectedLongitude; + + public SpecificDMSLocationAssertion(float myExpectedLatitude, float myExpectedLongitude) { + expectedLatitude = myExpectedLatitude; + expectedLongitude = myExpectedLongitude; + } + + @Override + protected void checkCoordinates(float latitude, float longitude) { + super.checkCoordinates(latitude, longitude); + Assert.assertThat("Latitude", (double) latitude, closeTo((double) expectedLatitude, 0.05)); + Assert.assertThat("Longitude", (double) longitude, closeTo((double) expectedLongitude, 0.05)); + } +} diff --git a/build.gradle b/build.gradle index 559fe9e..7a8ca00 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.1' + classpath 'com.android.tools.build:gradle:3.5.4' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -81,7 +81,7 @@ android { resValue "string", "google_maps_key", mapsApiKey() - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } useLibrary 'org.apache.http.legacy' compileOptions { @@ -131,10 +131,10 @@ android { jniLibs.srcDirs = ['libs'] } androidTest { - java.srcDirs = ['androidTest/java'] + java.srcDirs = ['androidTest/java', 'sharedTest/java'] } test { - java.srcDirs = ['test/java'] + java.srcDirs = ['test/java', 'sharedTest/java'] } } lintOptions { @@ -158,7 +158,12 @@ dependencies { implementation 'com.squareup.okio:okio:2.1.0' - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' - testImplementation 'junit:junit:4.12' + + testImplementation 'junit:junit:4.13.1' + testImplementation 'org.hamcrest:hamcrest-core:1.3' + testImplementation 'org.hamcrest:hamcrest-library:1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' } diff --git a/sharedTest/java/org/aprsdroid/app/testing/CoordinateMatcher.java b/sharedTest/java/org/aprsdroid/app/testing/CoordinateMatcher.java new file mode 100644 index 0000000..d33817b --- /dev/null +++ b/sharedTest/java/org/aprsdroid/app/testing/CoordinateMatcher.java @@ -0,0 +1,57 @@ +package org.aprsdroid.app.testing; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; + +import org.junit.Assert; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CoordinateMatcher { + private static final String NUMBER = "(-?\\d+(?:\\.\\d*)?)"; + private static final String dms_latitude_pattern = NUMBER + "°\\s*" + NUMBER + "'\\s*" + NUMBER + "\"\\s*([NS])"; + private static final String dms_longitude_pattern = NUMBER + "°\\s*" + NUMBER + "'\\s*" + NUMBER + "\"\\s*([EW])"; + private static final Pattern dms_latitude_regex = Pattern.compile(dms_latitude_pattern, Pattern.CASE_INSENSITIVE); + private static final Pattern dms_longitude_regex = Pattern.compile(dms_longitude_pattern, Pattern.CASE_INSENSITIVE); + + private static float convertField(CharSequence string, Pattern regex, String name) { + Matcher matcher = regex.matcher(string); + Assert.assertTrue(name + " not found", matcher.find()); + float value = 0; + try { + int degrees = Integer.parseInt(Objects.requireNonNull(matcher.group(1))); + Assert.assertThat(name + " degrees", degrees, greaterThanOrEqualTo(0)); + value = (float) degrees; + } catch (NumberFormatException ex) { + Assert.fail(name + " degree field not an integer"); + } + try { + int minutes = Integer.parseInt(Objects.requireNonNull(matcher.group(2))); + Assert.assertThat(name + " minutes", minutes, greaterThanOrEqualTo(0)); + value += (float) minutes / 60.0f; + } catch (NumberFormatException ex) { + Assert.fail(name + " minute field not an integer"); + } + try { + float seconds = Float.parseFloat(Objects.requireNonNull(matcher.group(3))); + Assert.assertThat(name + " seconds", seconds, greaterThanOrEqualTo(0.0f)); + value += seconds / 3600.0f; + } catch (NumberFormatException ex) { + Assert.fail(name + " seconds field not an number"); + } + String direction = Objects.requireNonNull(matcher.group(4)); + if (direction.equalsIgnoreCase("S") || direction.equalsIgnoreCase("W")) { + value *= -1.0f; + } + return value; + } + + public static float matchLatitude(CharSequence string) { + return convertField(string, dms_latitude_regex, "Latitude"); + } + + public static float matchLongitude(CharSequence string) { + return convertField(string, dms_longitude_regex, "Longitude"); + } +} diff --git a/test/java/org/aprsdroid/app/CoordinateTest.java b/test/java/org/aprsdroid/app/CoordinateTest.java new file mode 100644 index 0000000..3c812ab --- /dev/null +++ b/test/java/org/aprsdroid/app/CoordinateTest.java @@ -0,0 +1,43 @@ +package org.aprsdroid.app; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; + +import org.aprsdroid.app.testing.CoordinateMatcher; +import org.junit.Test; + +public class CoordinateTest { + // Reference data generated from https://www.pgc.umn.edu/apps/convert/ + private static final String providedNLatitude = "77° 15' 30\" N"; + private static final float expectedNLatitude = 77.258333f; + private static final String providedELongitude = "164° 45' 15\" E"; + private static final float expectedELongitude = 164.754167f; + private static final String providedSLatitude = "45° 30' 45\" S"; + private static final float expectedSLatitude = -45.5125f; + private static final String providedWLongitude = "97° 20' 40\" W"; + private static final float expectedWLongitude = -97.344444f; + + @Test + public void givenDMSLatitudeInN_whenParsingString_ThenShouldMatchDecimal() { + float value = CoordinateMatcher.matchLatitude(providedNLatitude); + assertThat("Latitude", (double) value, closeTo((double) expectedNLatitude, 1e-7)); + } + + @Test + public void givenDMSLongitudeInE_whenParsingString_ThenShouldMatchDecimal() { + float value = CoordinateMatcher.matchLongitude(providedELongitude); + assertThat("Longitude", (double) value, closeTo((double) expectedELongitude, 1e-7)); + } + + @Test + public void givenDMSLatitudeInS_whenParsingString_ThenShouldMatchDecimal() { + float value = CoordinateMatcher.matchLatitude(providedSLatitude); + assertThat("Latitude", (double) value, closeTo((double) expectedSLatitude, 1e-7)); + } + + @Test + public void givenDMSLongitudeInW_whenParsingString_ThenShouldMatchDecimal() { + float value = CoordinateMatcher.matchLongitude(providedWLongitude); + assertThat("Longitude", (double) value, closeTo((double) expectedWLongitude, 1e-7)); + } +}