Compare commits

...

9 Commits

18 changed files with 233 additions and 18 deletions

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Fenceless Grazing System App
This is the repository for the mobile application part of the Fenceless
Grazing System. The app is a rather standard Android application,
written in the Kotlin programming language. To build the application,
it is sufficient to run:
```Bash
./gradlew assembleDebug
```
This will produce an APK file which can then be loaded onto
an Android device or emulator.
Note that the application makes HTTP requests to a predefined
API server URL, currently `dev.danilafe.com`. This is not suitable
for testing if you do not have access to the domain. To
reconfigure the application to use a different API
URL, change the `apiUrl` entry in the application's `strings.xml` file:
```XML
<string name="apiUrl">http://your.domain </string>
```
The application will not be of much use if it is unable
to reach the Fenceless Grazing API server. The server's
repository can be found [here](https://dev.danilafe.com/CS-46X/server).

10
REVIEW.md Normal file
View File

@@ -0,0 +1,10 @@
# Code Review Changes
https://docs.google.com/document/d/19d5U-ieQyGVVNQjj0rUouzIqF53evEP6yss-wyqNXiQ/edit?usp=sharing
|Comment|Adjustment|
|-------|----------|
|The package `com.danilafe.fencelessgrazing` can be changed to something more relevant.| Package name follows standard conventions of reverse domain name. No changes made.|
| (General remark) commenting is sparse.| Every function and field in the codebase documented using KDoc; documentation intermittently updated on [student website](https://web.engr.oregonstate.edu/~fedorind/CS46X/app/index.html).|
| No unit tests| Not worth it to do UI tests; [cow description language](https://dev.danilafe.com/CS-46x/cdl) developed for testing databases elsewhere.|
| Missing README. | README added with compilation instructions; gradle wrapper adjusted to work out of the box.|

View File

@@ -13,6 +13,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ui.activities.BoundaryEditorActivity"></activity>
<activity android:name=".ui.activities.StatisticsActivity" />
<activity android:name=".ui.activities.CollarDetailActivity" />
<activity android:name=".ui.activities.CollarListActivity" />

View File

@@ -6,5 +6,6 @@ package com.danilafe.fencelessgrazing.model
* @param id the collar's internal unique identifier.
* @param name the collar's current designation in the system.
* @param stimulus the number of stimulus activation reports in the last 24 hours.
* @param boundary the collar's current valid grazing boundary.
*/
data class CollarDetails(val id: Int, val name: String, val stimulus: Int)
data class CollarDetails(val id: Int, val name: String, val stimulus: Int, val boundary: List<CollarPos>)

View File

@@ -2,6 +2,7 @@ package com.danilafe.fencelessgrazing.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import org.osmdroid.util.GeoPoint
/**
* GPS coordinate returned by many of the project's API endpoints.
@@ -10,5 +11,7 @@ import kotlinx.android.parcel.Parcelize
* @param latitude the latitude of the GPS point.
*/
@Parcelize
data class CollarPos(val longitude: String, val latitude: String) : Parcelable
data class CollarPos(val longitude: String, val latitude: String) : Parcelable {
fun toGeoPoint(): GeoPoint = GeoPoint(latitude.toDouble(), longitude.toDouble())
}

View File

@@ -7,6 +7,18 @@ import com.google.gson.Gson
import com.google.gson.JsonObject
import org.json.JSONObject
/**
* General POST request aimed at a protected endpoint of the API. The token can be retrieved
* via a [com.danilafe.fencelessgrazing.requests.LoginRequest], or, if you've made it past the login
* screen, via Shared Preferences.
*
* @param value the value to send as JSON to the API.
* @param baseUrl the base URL of the API.
* @param apiEndpoint the API endpoint to send the request to.
* @param token the token to use to authenticate with the API.
* @param listener the listener to be called when a response is received.
* @param error the error handler to be called if a request fails.
*/
open class AuthenticatedPostRequest<T>(
private val value: T,
baseUrl: String,

View File

@@ -3,6 +3,18 @@ package com.danilafe.fencelessgrazing.requests.authenticated
import com.android.volley.Response
import com.danilafe.fencelessgrazing.model.CollarPos
/**
* Request to the API's `/collar/<id>/boundary/set` endpoint, used to update a collar's
* valid grazing area. Note that although this request takes an arbitrary number of points,
* the collar itself only supports at most 10 vertices.
*
* @param baseUrl the base URl of the API.
* @param token the API token used for authentication.
* @param identifier the collar whose boundary is being set.
* @param coordinates the list of coordinates representing, in order, the new grazing boundary's vertices.
* @param listener the listener to be called when an OK response is received.
* @param error the error listener.
*/
class SetBoundaryRequest(
baseUrl: String,
token: String,

View File

@@ -18,15 +18,42 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
/**
* Activity used to update a grazing boundary for a particular collar.
*/
class BoundaryEditorActivity : AppCompatActivity(),
Marker.OnMarkerDragListener,
Marker.OnMarkerClickListener {
/**
* The map displaying the boundary vertices and polygon.
*/
private lateinit var map: MapView
/**
* The list of markers representing the new grazing boundary's edges.
*/
private val markers: MutableList<Marker> = mutableListOf()
/**
* The polygon used to display the grazing area on the [map].
*/
private lateinit var polygon: GrazingPolygon
/**
* The center around which the [map] was originally centered,
* used also for creating new vertices.
*/
private lateinit var center: GeoPoint
/**
* The identifier of the collar whose boundary is being changed.
*/
private var identifier: Int = -1
/**
* The Volley queue used for sending API requests.
*/
private lateinit var queue: RequestQueue
override fun onCreate(savedInstanceState: Bundle?) {
@@ -34,9 +61,7 @@ class BoundaryEditorActivity : AppCompatActivity(),
setContentView(R.layout.activity_boundary_editor)
val requestedCenter = intent.getParcelableExtra<CollarPos>("center")!!
center = GeoPoint(
requestedCenter.longitude.toDouble(),
requestedCenter.latitude.toDouble())
center = requestedCenter.toGeoPoint()
identifier = intent.getIntExtra("identifier", -1)
queue = Volley.newRequestQueue(applicationContext)
@@ -47,6 +72,9 @@ class BoundaryEditorActivity : AppCompatActivity(),
map.controller.setCenter(center)
}
/**
* Removes a marker from the [map].
*/
private fun removeMarker(m: Marker) {
markers.remove(m)
map.overlays.remove(m)
@@ -54,6 +82,10 @@ class BoundaryEditorActivity : AppCompatActivity(),
map.invalidate()
}
/**
* Sends the current list of points to the API endpoint, closing
* the activity if successful.
*/
fun sendBoundary(v: View) {
val request = SetBoundaryRequest(
getString(R.string.apiUrl),
@@ -74,6 +106,10 @@ class BoundaryEditorActivity : AppCompatActivity(),
queue.add(request)
}
/**
* Adds a new marker to the [map], starting it at the previously-configured
* [center].
*/
fun addMarker(v : View) {
if(markers.size >= 10) {
Toast.makeText(
@@ -94,6 +130,11 @@ class BoundaryEditorActivity : AppCompatActivity(),
map.invalidate()
}
/**
* Ensures that the polygon's vertices are synchronized
* with the user-placed markers, making it so that the
* grazing area is visible to the user.
*/
private fun updatePolygon() {
polygon.points.clear()
if(markers.isEmpty()) return

View File

@@ -1,15 +1,18 @@
package com.danilafe.fencelessgrazing.ui.activities
import android.content.Intent
import android.graphics.Color
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.TextView
import android.widget.Toast
import com.android.volley.RequestQueue
import com.android.volley.Response
import com.android.volley.toolbox.Volley
import com.danilafe.fencelessgrazing.R
import com.danilafe.fencelessgrazing.model.CollarPos
import com.danilafe.fencelessgrazing.model.Polygon
import com.danilafe.fencelessgrazing.requests.authenticated.CollarDetailRequest
import com.danilafe.fencelessgrazing.requests.authenticated.CollarHistoryRequest
@@ -174,7 +177,7 @@ class CollarDetailActivity : AppCompatActivity() {
token,
Response.Listener {
dataPoints =
it.map { p -> GeoPoint(p.longitude.toDouble(), p.latitude.toDouble()) }
it.map { p -> p.toGeoPoint() }
},
Response.ErrorListener {
Toast.makeText(this, "Failed to retrieve history of collar", Toast.LENGTH_SHORT)
@@ -199,6 +202,19 @@ class CollarDetailActivity : AppCompatActivity() {
queue.add(detailRequest)
}
/**
* Open the [BoundaryEditorActivity] to edit the current collar's grazing boundary.
*/
fun changeGrazingBoundary(v : View) {
startActivity(Intent(this, BoundaryEditorActivity::class.java).apply {
val center = dataPoints.lastOrNull()?.let {
CollarPos(it.longitude.toString(), it.latitude.toString())
} ?: CollarPos("0", "0")
putExtra("center", center)
putExtra("identifier", collarId)
})
}
/**
* Given a new location history of the animal, configures
* updates the [map] to include the relevant elements (such as the animal's
@@ -235,7 +251,7 @@ class CollarDetailActivity : AppCompatActivity() {
*/
private fun updateBoundingBox(oldValue: Polygon?, polygon: Polygon) {
if(oldValue == null) map.overlays.add(mapPolygon)
val points = polygon.dataPoints.map { GeoPoint(it.latitude.toDouble(), it.longitude.toDouble()) }
val points = polygon.dataPoints.map { it.toGeoPoint() }
val polygonPoints = points.toMutableList()
polygonPoints.add(polygonPoints[0])
mapPolygon.points = polygonPoints

View File

@@ -196,7 +196,7 @@ class CollarListActivity : AppCompatActivity() {
centerSet = true
val averageLongitude = summaries.map { it.pos.longitude.toDouble() }.average()
val averageLatitude = summaries.map { it.pos.latitude.toDouble() }.average()
map.controller.setCenter(GeoPoint(averageLongitude, averageLatitude))
map.controller.setCenter(GeoPoint(averageLatitude, averageLongitude))
}
/**
@@ -208,7 +208,7 @@ class CollarListActivity : AppCompatActivity() {
// Create or update overlay
val overlay = collarOverlays[it.id] ?: Marker(map)
overlay.title = it.name
overlay.position = GeoPoint(it.pos.longitude.toDouble(), it.pos.latitude.toDouble())
overlay.position = it.pos.toGeoPoint()
// Store new / existing overlay.
if(!collarOverlays.containsKey(it.id)) map.overlays.add(overlay)

View File

@@ -4,6 +4,9 @@ import android.graphics.Color
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Polygon
/**
* OpenStreetMaps polygon with some default visual settings for the Fenceless Grazing System.
*/
class GrazingPolygon(map: MapView) : Polygon(map) {
init {
fillPaint.color = Color.parseColor("#32a852")

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activities.BoundaryEditorActivity">
<org.osmdroid.views.MapView
android:id="@+id/editorMap"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/saveButtonLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
</org.osmdroid.views.MapView>
<LinearLayout
android:id="@+id/saveButtonLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<Button
android:onClick="sendBoundary"
android:id="@+id/saveBoundaryButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/saveBoundary" />
<Button
android:onClick="addMarker"
android:id="@+id/addVertexButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/addVertex" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -29,9 +29,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/collarName" />
app:layout_constraintTop_toBottomOf="@+id/changeBoundaryButton" />
<TextView
android:id="@+id/collarStimulus"
@@ -42,4 +41,14 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/collarPos" />
<Button
android:id="@+id/changeBoundaryButton"
style="@style/Widget.AppCompat.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="changeGrazingBoundary"
android:text="@string/changeGrazingBoundary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/collarName" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -8,4 +8,7 @@
<string name="collarSummaryStimulus">Required %1d stimuli in the last 24 hours</string>
<string name="distanceTraveled">Distance Traveled</string>
<string name="viewStatistics">View Statistics</string>
<string name="saveBoundary">Save Boundary</string>
<string name="addVertex">Add Vertex</string>
<string name="changeGrazingBoundary">Change Grazing Boundary</string>
</resources>

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,5 @@
#Sat Feb 01 14:34:24 PST 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

22
gradlew vendored
View File

@@ -1,5 +1,21 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
@@ -28,7 +44,7 @@ APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
@@ -109,8 +125,8 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`

18
gradlew.bat vendored
View File

@@ -1,3 +1,19 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@@ -14,7 +30,7 @@ set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome