Add code for initial version

This commit is contained in:
Marius Lindvall 2019-08-23 22:58:49 +02:00
parent 84d14fd168
commit a7d525a5c3
53 changed files with 2571 additions and 82 deletions

82
.gitignore vendored
View file

@ -1,82 +0,0 @@
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/

13
android/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild

4
android/.idea/encodings.xml generated Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

15
android/.idea/gradle.xml generated Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<compositeConfiguration>
<compositeBuild compositeDefinitionSource="SCRIPT" />
</compositeConfiguration>
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

9
android/.idea/misc.xml generated Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

12
android/.idea/runConfigurations.xml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

6
android/.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

1
android/app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

29
android/app/build.gradle Normal file
View file

@ -0,0 +1,29 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "info.varden.hauk"
minSdkVersion 23
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

21
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,27 @@
package info.varden.hauk;
import android.content.Context;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("info.varden.hauk", appContext.getPackageName());
}
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="info.varden.hauk">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_icon"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver android:name=".notify.CopyLinkReceiver" android:exported="true">
<intent-filter>
<action android:name="info.varden.hauk.COPY_LINK" />
</intent-filter>
</receiver>
<receiver android:name=".notify.StopSharingReceiver" android:exported="true">
<intent-filter>
<action android:name="info.varden.hauk.RETURN_TO_APP" />
</intent-filter>
</receiver>
<service android:name=".service.LocationPushService" android:enabled="true">
<intent-filter>
<action android:name="info.varden.hauk.LOCATION_SERVICE" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,96 @@
package info.varden.hauk;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
/**
* A helper class for creating dialogs on the main activity.
*
* @author Marius Lindvall
*/
public class DialogService {
private final Context ctx;
public DialogService(Context ctx) {
this.ctx = ctx;
}
/**
* Shows a dialog box with an OK button.
*
* @param title A string resource representing the title of the dialog box.
* @param message A string resource representing the body of the dialog box.
* @param onOK A callback that is run when the user clicks the OK button.
*/
public void showDialog(int title, int message, final Runnable onOK) {
showDialog(title, this.ctx.getString(message), onOK);
}
/**
* Shows a dialog box with an OK button.
*
* @param title A string resource representing the title of the dialog box.
* @param message A string representing the body of the dialog box.
* @param onOK A callback that is run when the user clicks the OK button.
*/
public void showDialog(int title, String message, final Runnable onOK) {
showDialog(this.ctx.getString(title), message, onOK);
}
private void showDialog(String title, String message, final Runnable onOK) {
AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this.ctx);
dlgAlert.setMessage(message);
dlgAlert.setTitle(title);
dlgAlert.setPositiveButton("OK", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (onOK != null) onOK.run();
}
});
dlgAlert.setCancelable(false);
dlgAlert.create().show();
}
/**
* Shows a dialog box with OK and Cancel buttons.
*
* @param title A string resource representing the title of the dialog box.
* @param message A string resource representing the body of the dialog box.
* @param onOK A callback that is run when the user clicks the OK button.
* @param onCancel A callback that is run when the user clicks the cancel button.
*/
public void showDialog(int title, int message, final Runnable onOK, final Runnable onCancel) {
showDialog(title, this.ctx.getString(message), onOK, onCancel);
}
/**
* Shows a dialog box with OK and Cancel buttons.
*
* @param title A string resource representing the title of the dialog box.
* @param message A string representing the body of the dialog box.
* @param onOK A callback that is run when the user clicks the OK button.
* @param onCancel A callback that is run when the user clicks the cancel button.
*/
public void showDialog(int title, String message, final Runnable onOK, final Runnable onCancel) {
showDialog(this.ctx.getString(title), message, onOK, onCancel);
}
private void showDialog(String title, String message, final Runnable onOK, final Runnable onCancel) {
AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this.ctx);
dlgAlert.setMessage(message);
dlgAlert.setTitle(title);
dlgAlert.setPositiveButton(this.ctx.getString(R.string.btn_ok), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (onOK != null) onOK.run();
}
});
dlgAlert.setNegativeButton(this.ctx.getString(R.string.btn_cancel), new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
if (onCancel != null) onCancel.run();
}
});
dlgAlert.setCancelable(false);
dlgAlert.create().show();
}
}

View file

@ -0,0 +1,134 @@
package info.varden.hauk;
import android.os.AsyncTask;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Map;
/**
* An asynchronous task that POSTs data to a given URL with the given POST fields.
*
* @author Marius Lindvall
*/
public class HTTPThread extends AsyncTask<HTTPThread.Request, String, HTTPThread.Response> {
// A callback that is called after the request is completed. Contains received data, or errors,
// if applicable.
private final Callback callback;
public HTTPThread(Callback callback) {
this.callback = callback;
}
@Override
protected Response doInBackground(Request... data) {
try {
// Create a URL-encoded data body for the HTTP request. Only the first request in the
// array is ever used.
StringBuilder sb = new StringBuilder();
boolean first = false;
for (Map.Entry<String, String> entry : data[0].data.entrySet()) {
if (first) first = false;
else sb.append("&");
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
sb.append("=");
sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
}
// Open a connection to the Hauk server and post the data.
URL url = new URL(data[0].url);
HttpURLConnection client = (HttpURLConnection) url.openConnection();
client.setConnectTimeout(10000);
client.setRequestMethod("POST");
client.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
client.setRequestProperty("User-Agent", "Hauk/" + BuildConfig.VERSION_NAME + " " + System.getProperty("http.agent"));
client.setDoInput(true);
client.setDoOutput(true);
OutputStream os = client.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
writer.write(sb.toString());
writer.flush();
os.close();
int response = client.getResponseCode();
if (response == HttpURLConnection.HTTP_OK) {
// The response should be returned as an array of strings where each element of the
// array is one line of output. Hauk uses this array as an argument array when
// processing the response.
String line;
ArrayList<String> lines = new ArrayList<>();
BufferedReader br = new BufferedReader(new InputStreamReader(client.getInputStream()));
while ((line = br.readLine()) != null) {
lines.add(line);
}
br.close();
return new Response(null, lines.toArray(new String[lines.size()]));
} else {
// Hauk only returns HTTP 200; any other response should be considered an error.
throw new Exception("Received HTTP " + response + " from server!");
}
} catch (Exception ex) {
// If an exception occurred, return no data.
return new Response(ex, null);
}
}
@Override
protected void onPostExecute(Response result) {
// Call the provided callback once a response has been obtained.
this.callback.run(result);
}
/**
* A structure representing an HTTP POST request. Contains a URL as well as a map of key-value
* data to be posted to the URL.
*/
public static class Request {
private final String url;
private final Map<String, String> data;
public Request(String url, Map<String, String> data) {
this.url = url;
this.data = data;
}
}
/**
* A structure representing an HTTP response. Contains either an array of strings representing
* each line of the response body, or an exception, if one occurred during the request.
*/
public static class Response {
private final Exception ex;
private final String[] data;
private Response(Exception ex, String[] data) {
this.ex = ex;
this.data = data;
}
public Exception getException() {
return this.ex;
}
public String[] getData() {
return this.data;
}
}
/**
* A callback that is run when the HTTP request is complete. The callback is provided the
* response.
*/
public abstract static class Callback {
public abstract void run(Response resp);
}
}

View file

@ -0,0 +1,319 @@
package info.varden.hauk;
import android.Manifest;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import java.io.IOException;
import java.net.MalformedURLException;
import java.util.HashMap;
import info.varden.hauk.service.LocationPushService;
/**
* The main activity for Hauk.
*
* @author Marius Lindvall
*/
public class MainActivity extends AppCompatActivity {
// UI elements on activity_main.xml
private EditText txtServer;
private EditText txtPassword;
private EditText txtDuration;
private EditText txtInterval;
private Button btnShare;
private Button btnLink;
// The publicly sharable link received from the Hauk server during handshake
private String viewLink;
// A helper utility class for displaying dialog windows/message boxes.
private DialogService diagSvc;
// A runnable task that is executed when location sharing stops. It clears the persistent Hauk
// notification, unregisters the location pusher and resets the UI to a fresh state.
private StopSharingTask stopTask;
// A runnable task that resets the UI to a fresh state.
private Runnable resetTask;
private static final int MY_PERMISSIONS_REQUEST_FINE_LOCATION = 123;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
setClassVariables();
loadPreferences();
}
@Override
protected void onDestroy() {
stopTask.setActivityDestroyed();
super.onDestroy();
}
/**
* On-tap handler for the "start sharing" and "stop sharing" button.
*/
public void startSharing(View view) {
// If there is an executable stop task, that means that sharing is already active. Shut down
// the share by running the stop task instead of starting a new share.
if (stopTask.canExecute()) {
stopTask.run();
return;
}
// Temporarily disable the share button while we attempt to connect to the Hauk backend.
btnShare.setEnabled(false);
String server = txtServer.getText().toString();
final String password = txtPassword.getText().toString();
int duration = Integer.parseInt(txtDuration.getText().toString());
final int interval = Integer.parseInt(txtInterval.getText().toString());
// Save connection preferences for next launch, so the user doesn't have to enter URL etc.
// every time.
setPreferences(server, duration, interval);
// Create a "full" server address, with a following slash if it is missing. This is used to
// construct subpaths for the Hauk backend.
final String serverFull = server.endsWith("/") ? server : server + "/";
// The backend takes duration in seconds, so convert the minutes supplied by the user.
final int durationSec = duration * 60;
// Check for location permission and prompt the user if missing. This returns because the
// checking function creates async dialogs here - the user is prompted to press the button
// again instead.
if (!hasLocationPermission()) return;
final LocationManager locMan = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
boolean isGPSEnabled = false;
try {
isGPSEnabled = locMan.isProviderEnabled(LocationManager.GPS_PROVIDER);
} catch (Exception ex) {};
if (!isGPSEnabled) {
diagSvc.showDialog(R.string.err_client, R.string.err_location_disabled, resetTask);
return;
}
// Create a progress dialog while doing initial handshake. This could end up taking a while
// (e.g. if the host is unreachable, it will eventually time out), and having a progress bar
// makes for better UX since it visually shows that something is actually happening in the
// background.
final ProgressDialog prog = new ProgressDialog(this);
prog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
prog.setTitle(R.string.prog_title);
prog.setMessage(getString(R.string.prog_body));
prog.setIndeterminate(true);
prog.setCancelable(false);
prog.show();
// Create a handshake request and handle the response. The handshake transmits the duration
// and interval to the server and waits for the server to return a session ID to confirm
// session creation.
HashMap<String, String> data = new HashMap<>();
data.put("pwd", password);
data.put("dur", String.valueOf(durationSec));
data.put("int", String.valueOf(interval));
HTTPThread req = new HTTPThread(new HTTPThread.Callback() {
@Override
public void run(HTTPThread.Response resp) {
prog.dismiss();
// An exception may have occurred, but it cannot be thrown because this is a
// callback. Instead, the exception (if any) is stored in the response object.
Exception e = resp.getException();
if (e == null) {
// A successful session initiation contains "OK" on line 1, the session ID on
// line 2, and a publicly sharable tracking link on line 3.
String[] data = resp.getData();
if (data[0].equals("OK")) {
String session = data[1];
viewLink = data[2];
// We now have a link to share, so we enable the link sharing button.
btnLink.setEnabled(true);
// Even though we previously requested location permission, we still have to
// check for it when we actually use the location API (user could have
// disabled it while connecting).
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
// Create a client that receives location updates and pushes these to
// the Hauk backend.
Intent pusher = new Intent(MainActivity.this, LocationPushService.class);
pusher.setAction(LocationPushService.ACTION_ID);
pusher.putExtra("baseUrl", serverFull);
pusher.putExtra("viewUrl", viewLink);
pusher.putExtra("session", session);
pusher.putExtra("interval", (long) interval * 1000L);
pusher.putExtra("stopTask", ReceiverDataRegistry.register(stopTask));
if (Build.VERSION.SDK_INT >= 26) {
startForegroundService(pusher);
} else {
startService(pusher);
}
// When both the notification and pusher are created, we can update the
// stop task with these so that they can be canceled when the location
// share ends.
stopTask.updateTask(pusher);
final Handler handler = new Handler();
// stopTask is scheduled for expiration, but it could also be called if
// the user manually stops the share, or if the app is destroyed.
handler.postDelayed(stopTask, durationSec * 1000L);
// Now that sharing is finally active, we can re-enable the start
// button, turn it into a stop button, and inform the user.
btnShare.setEnabled(true);
btnShare.setText(R.string.btn_stop);
diagSvc.showDialog(R.string.ok_title, R.string.ok_message, null);
} else {
diagSvc.showDialog(R.string.err_client, R.string.err_missing_perms, resetTask);
}
} else {
// If the first line of the response is not "OK", an error of some sort has
// occurred and should be displayed to the user.
StringBuilder err = new StringBuilder();
for (String line : data) {
err.append(line);
err.append("\n");
}
diagSvc.showDialog(R.string.err_server, err.toString(), resetTask);
}
} else if (e instanceof MalformedURLException) {
e.printStackTrace();
diagSvc.showDialog(R.string.err_client, R.string.err_malformed_url, resetTask);
} else if (e instanceof IOException) {
e.printStackTrace();
diagSvc.showDialog(R.string.err_connect, e.getMessage(), resetTask);
} else {
e.printStackTrace();
diagSvc.showDialog(R.string.err_server, e.getMessage(), resetTask);
}
}
});
req.execute(new HTTPThread.Request(serverFull + "api/create.php", data));
}
/**
* On-tap handler for the "share link" button. Opens a share menu.
*/
public void shareLink(View view) {
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_SUBJECT, getResources().getString(R.string.share_subject));
shareIntent.putExtra(Intent.EXTRA_TEXT, viewLink);
startActivity(Intent.createChooser(shareIntent, getResources().getString(R.string.share_via)));
}
/**
* Checks whether or not the user granted Hauk permission to use their device location. If
* permission has not been granted, this function creates a dialog which runs asynchronously,
* meaning this function does not wait until permission has been granted before it returns.
*
* @return true if permission is granted, false if the user needs to be asked.
*/
private boolean hasLocationPermission() {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
// Show a rationale first before requesting location permission, giving users the chance
// to cancel the request if they so desire. Users are informed that they must click the
// "start sharing" button again after they have granted the permission.
diagSvc.showDialog(R.string.req_perms_title, R.string.req_perms_message, new Runnable() {
/**
* Function that runs if the user accepts the location request rationale via the
* OK button.
*/
@Override
public void run() {
btnShare.setEnabled(true);
btnLink.setEnabled(false);
ActivityCompat.requestPermissions(MainActivity.this, new String[] {
Manifest.permission.ACCESS_FINE_LOCATION
}, MY_PERMISSIONS_REQUEST_FINE_LOCATION);
}
}, new Runnable() {
/**
* Function that runs if the user accepts the location request rationale via the
* Cancel button.
*/
@Override
public void run() {
btnShare.setEnabled(true);
btnLink.setEnabled(false);
}
});
return false;
} else {
return true;
}
}
/**
* This function is called by onCreate() to initialize class-level variables for usage in this
* activity.
*/
private void setClassVariables() {
txtServer = findViewById(R.id.txtServer);
txtPassword = findViewById(R.id.txtPassword);
txtDuration = findViewById(R.id.txtDuration);
txtInterval = findViewById(R.id.txtInterval);
btnShare = findViewById(R.id.btnShare);
btnLink = findViewById(R.id.btnLink);
resetTask = new Runnable() {
/**
* A function which resets the user interface to its default settings, as if the app was
* just opened. Used to reset the UI after errors and after sharing has expired.
*/
@Override
public void run() {
btnShare.setEnabled(true);
btnShare.setText(R.string.btn_start);
btnLink.setEnabled(false);
}
};
diagSvc = new DialogService(this);
stopTask = new StopSharingTask(this, diagSvc, resetTask);
}
private void loadPreferences() {
SharedPreferences settings = getApplicationContext().getSharedPreferences("connectionPrefs", MODE_PRIVATE);
txtServer.setText(settings.getString("server", ""));
txtDuration.setText(String.valueOf(settings.getInt("duration", 30)));
txtInterval.setText(String.valueOf(settings.getInt("interval", 1)));
}
private void setPreferences(String server, int duration, int interval) {
SharedPreferences settings = getApplicationContext().getSharedPreferences("connectionPrefs", MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
editor.putString("server", server);
editor.putInt("duration", duration);
editor.putInt("interval", interval);
editor.apply();
}
}

View file

@ -0,0 +1,19 @@
package info.varden.hauk;
import java.util.HashMap;
import java.util.Random;
public class ReceiverDataRegistry {
private static HashMap<Integer, Object> data = new HashMap<>();
private static Random random = new Random();
public static int register(Object obj) {
int index = random.nextInt();
data.put(index, obj);
return index;
}
public static Object retrieve(int index) {
return data.remove(index);
}
}

View file

@ -0,0 +1,121 @@
package info.varden.hauk;
import android.content.Context;
import android.content.Intent;
import java.util.HashMap;
/**
* This class is a runnable task that will stop location sharing and reset the UI to the state it
* was in when the app launched. Only one copy of this task exists in the MainActivity class. This
* class should not be instantiated elsewhere.
*
* @author Marius Lindvall
*/
public class StopSharingTask implements Runnable {
private final Context ctx;
private final DialogService diagSvc;
private final Runnable resetTask;
// The task does not have a notification and pusher until updateTask() is called, and the stop
// task cannot be executed until that is the case. If this task is executable, then location
// sharing is currently active.
private boolean canExecute = false;
private Intent pusher = null;
// This task can be run when the activity no longer exists. In that case, do not attempt to
// reset the UI and show dialogs.
private boolean activityExists = true;
// If the user stops sharing early, a request should be sent to the server to erase the session.
// Store details about the current session here.
private String baseUrl = null;
private String session = null;
protected StopSharingTask(Context ctx, DialogService diagSvc, Runnable resetTask) {
this.ctx = ctx;
this.diagSvc = diagSvc;
this.resetTask = resetTask;
}
/**
* Sets the stop task executable. When the task is executed with run(), the provided
* notification is cleared and the pusher unregistered from the Android location manager.
*
* @param pusher A location handler that should be unregistered when sharing is stopped.
*/
public void updateTask(Intent pusher) {
this.pusher = pusher;
this.canExecute = true;
}
/**
* Informs the stop task that the main activity no longer exists, and that it should not attempt
* to reset the UI or show dialogs.
*/
public void setActivityDestroyed() {
this.activityExists = false;
}
/**
* When a new sharing session is initiated, call this function with the connection settings to
* register a handler that sends a stop-sharing request to the server when the share should be
* stopped.
*
* @param baseUrl The base URL of the remote Hauk backend.
* @param session The session ID provided by the Hauk backend.
*/
public void setSession(String baseUrl, String session) {
this.baseUrl = baseUrl;
this.session = session;
}
/**
* Checks whether or not the stop task can be executed. The task is only executable if location
* sharing is active.
*
* @return true if executable, false otherwise.
*/
public boolean canExecute() {
return this.canExecute;
}
/**
* Executes the stop task. When run, this will unregister the location handler, clear Hauk's
* persistent notification, reset the UI to a fresh state and inform the user that sharing has
* been stopped.
*/
@Override
public void run() {
if (!this.canExecute) return;
this.canExecute = false;
this.ctx.stopService(this.pusher);
// If a session is currently active, send a cancellation request to the backend to remove
// session data from the server.
if (this.baseUrl != null && this.session != null) {
HashMap<String, String> data = new HashMap<>();
data.put("sid", this.session);
HTTPThread req = new HTTPThread(new HTTPThread.Callback() {
@Override
public void run(HTTPThread.Response resp) {
resetApp();
}
});
req.execute(new HTTPThread.Request(this.baseUrl + "api/stop.php", data));
} else {
resetApp();
}
}
private void resetApp() {
if (this.activityExists) {
this.resetTask.run();
this.diagSvc.showDialog(R.string.ended_title, R.string.ended_message, this.resetTask);
} else {
// If the main activity is already destroyed, there is no reason to keep the app
// running.
System.exit(0);
}
}
}

View file

@ -0,0 +1,29 @@
package info.varden.hauk.notify;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import info.varden.hauk.R;
/**
* A broadcast receiver for the "Copy link" action on the persistent Hauk notification.
*
* @author Marius Lindvall
*/
public class CopyLinkReceiver extends HaukBroadcastReceiver<String> {
private static final String ACTION_ID = "info.varden.hauk.COPY_LINK";
@Override
public String getActionID() {
return ACTION_ID;
}
@Override
public void handle(Context ctx, String data) {
// Copy the link to the clipboard.
ClipData clip = ClipData.newPlainText(ctx.getString(R.string.action_copied), data);
((ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(clip);
}
}

View file

@ -0,0 +1,39 @@
package info.varden.hauk.notify;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import info.varden.hauk.ReceiverDataRegistry;
/**
* A superclass for broadcast receivers, used together with the Receiver class to handle callbacks
* from users clicking buttons in Hauk notifications. Passes the data object from Receiver to the
* subclass of this class for processing.
*
* @author Marius Lindvall
* @param <T> The type of data this broadcast receiver is capable of processing.
*/
public abstract class HaukBroadcastReceiver<T> extends BroadcastReceiver {
/**
* A function that provides a unique broadcast action ID that this receiver should handle.
*
* @return A broadcast activity ID.
*/
public abstract String getActionID();
/**
* Callback for handling the broadcast data.
* @param ctx Android application context.
* @param data The data object provided to the Receiver class.
*/
public abstract void handle(Context ctx, T data);
@Override
public final void onReceive(Context ctx, Intent intent) {
// Retrieve the registry index of the data stored for this receiver, then pass that data on
// to the subclass.
int index = intent.getIntExtra(Intent.EXTRA_INDEX, -1);
handle(ctx, (T) ReceiverDataRegistry.retrieve(index));
}
}

View file

@ -0,0 +1,73 @@
package info.varden.hauk.notify;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import java.util.Random;
/**
* A base class for all Hauk notifications. Handles registration into the notification registry and
* various internally required calls.
*
* @author Marius Lindvall
*/
public abstract class HaukNotification {
private final Context ctx;
private final int id;
private static final String NOTIFY_CHANNEL_ID = "hauk";
private static final String NOTIFY_CHANNEL_NAME = "Hauk";
// Whether or not the notification has been despawned.
private boolean isCanceled;
public HaukNotification(Context ctx) {
this.ctx = ctx;
this.isCanceled = false;
// Generate a random non-zero ID for the notification.
Random random = new Random();
int id;
do id = random.nextInt(); while (id == 0);
this.id = id;
}
public final Context getContext() {
return this.ctx;
}
public final int getID() {
return this.id;
}
public abstract int getImportance();
public abstract void build(NotificationCompat.Builder builder) throws Exception;
/**
* Creates a notification instance that can be displayed using NotificationManager.
*
* @return A Notification instance.
* @throws Exception if an exception was thrown during the notification build process.
*/
public final Notification create() throws Exception {
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, NOTIFY_CHANNEL_ID);
// Pass construction on to the subclass.
build(builder);
// On Android >= 8.0, notifications need to be assigned a channel ID.
if (Build.VERSION.SDK_INT >= 26) {
NotificationChannel channel = new NotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, getImportance());
NotificationManager notiMan = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
notiMan.createNotificationChannel(channel);
builder.setChannelId(NOTIFY_CHANNEL_ID);
}
return builder.build();
}
}

View file

@ -0,0 +1,56 @@
package info.varden.hauk.notify;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import info.varden.hauk.ReceiverDataRegistry;
/**
* This class is used to create intents for use in notification buttons that can store an object for
* retrieval by the associated receiver class. The class maintains a registry of objects for each
* receiver registered; these objects are returned to the receiver when it is called.
*
* @author Marius Lindvall
* @param <T> The type of data to be passed to the receiving listener.
*/
public class Receiver<T> {
private final Class<? extends HaukBroadcastReceiver<T>> receiver;
private final Context ctx;
private final T data;
/**
* Creates a receiver instance.
*
* @param ctx The Android application context.
* @param receiver The class that Android will instantiate when the proper broadcast is issued.
* @param data A data object that will be passed to the broadcast receiver instance.
*/
public Receiver(Context ctx, Class<? extends HaukBroadcastReceiver<T>> receiver, T data) {
this.receiver = receiver;
this.ctx = ctx;
this.data = data;
}
/**
* Creates a PendingIntent from this receiver. Used to add handlers to notification buttons.
*
* @return A PendingIntent for use in a notification action.
* @throws InstantiationException if the broadcast receiver cannot be instantiated.
* @throws IllegalAccessException if the broadcast receiver hides the action ID function.
*/
public PendingIntent toPending() throws InstantiationException, IllegalAccessException {
// Create a new intent for the receiver.
Intent intent = new Intent(this.ctx, this.receiver);
// Retrieve the action ID from the broadcast receiver class.
HaukBroadcastReceiver<T> instance = this.receiver.newInstance();
intent.setAction(instance.getActionID());
// Store the provided data in the registry for later retrieval, and pass the data index to
// the intent.
intent.putExtra(Intent.EXTRA_INDEX, ReceiverDataRegistry.register(this.data));
return PendingIntent.getBroadcast(ctx, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
}

View file

@ -0,0 +1,60 @@
package info.varden.hauk.notify;
import android.app.NotificationManager;
import android.content.Context;
import androidx.core.app.NotificationCompat;
import info.varden.hauk.R;
import info.varden.hauk.StopSharingTask;
/**
* Hauk's persistent notification that prevents Hauk from being stopped while in the background.
*
* @author Marius Lindvall
*/
public class SharingNotification extends HaukNotification {
// The Hauk backend base URL e.g. https://example.com/.
private final String baseUrl;
// The publicly sharable URL for this share, e.g. https://example.com?ABCD-1234.
private final String viewUrl;
// A task to be executed when sharing stops. In the case of this notification, it is executed if
// the user taps the "Stop sharing" button on the notification.
private final StopSharingTask stopSharingTask;
/**
* Creates a persistent notification.
*
* @param ctx Android application context.
* @param baseUrl The Hauk backend base URL.
* @param viewUrl The publicly sharable link for this share.
* @param stopSharingTask A task to run if the user stops sharing their location.
*/
public SharingNotification(Context ctx, String baseUrl, String viewUrl, StopSharingTask stopSharingTask) {
super(ctx);
this.baseUrl = baseUrl;
this.viewUrl = viewUrl;
this.stopSharingTask = stopSharingTask;
}
@Override
public int getImportance() {
return NotificationManager.IMPORTANCE_DEFAULT;
}
@Override
public void build(NotificationCompat.Builder builder) throws Exception {
builder.setContentTitle(getContext().getString(R.string.notify_title));
builder.setContentText(String.format(getContext().getString(R.string.notify_body), baseUrl));
builder.setSmallIcon(R.drawable.ic_notify);
builder.setPriority(NotificationCompat.PRIORITY_DEFAULT);
// Add "Copy link" and "Stop sharing" buttons to the notification.
builder.addAction(R.drawable.ic_notify, getContext().getString(R.string.action_copy), new Receiver<>(getContext(), CopyLinkReceiver.class, this.viewUrl).toPending());
builder.addAction(R.drawable.ic_notify, getContext().getString(R.string.action_stop), new Receiver<>(getContext(), StopSharingReceiver.class, this.stopSharingTask).toPending());
builder.setOngoing(true);
}
}

View file

@ -0,0 +1,26 @@
package info.varden.hauk.notify;
import android.content.Context;
import info.varden.hauk.StopSharingTask;
/**
* A broadcast receiver for the "Stop sharing" button on the persistent Hauk notification.
*
* @author Marius Lindvall
*/
public class StopSharingReceiver extends HaukBroadcastReceiver<StopSharingTask> {
public static final String ACTION_ID = "info.varden.hauk.RETURN_TO_APP";
@Override
public String getActionID() {
return ACTION_ID;
}
@Override
public void handle(Context ctx, StopSharingTask data) {
// Run the stop sharing task to end location sharing.
data.run();
}
}

View file

@ -0,0 +1,120 @@
package info.varden.hauk.service;
import android.Manifest;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.IBinder;
import java.util.HashMap;
import info.varden.hauk.HTTPThread;
import info.varden.hauk.ReceiverDataRegistry;
import info.varden.hauk.StopSharingTask;
import info.varden.hauk.notify.SharingNotification;
/**
* This class is a location listener that POSTs all location updates to Hauk as it receives them. It
* creates a persistent notification when it launches in order to stay running while the app is
* minimized.
*
* @author Marius Lindvall
*/
public class LocationPushService extends Service implements LocationListener {
public static final String ACTION_ID = "info.varden.hauk.LOCATION_SERVICE";
// The base URL of the Hauk server.
private String baseUrl;
// The publicly sharable link for the current share.
private String viewUrl;
// A task that should be run when sharing ends, either automatically or by user request.
private StopSharingTask stopTask;
private String session;
private long interval;
private LocationManager locMan;
/**
* Called when the Service is created.
*/
@Override
public void onCreate() {
this.locMan = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
this.baseUrl = intent.getStringExtra("baseUrl");
this.viewUrl = intent.getStringExtra("viewUrl");
this.session = intent.getStringExtra("session");
this.interval = intent.getLongExtra("interval", -1L);
this.stopTask = (StopSharingTask) ReceiverDataRegistry.retrieve(intent.getIntExtra("stopTask", -1));
try {
// Even though we previously requested location permission, we still have to check for
// it when we actually use the location API.
if (this.interval >= 0L && checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
this.stopTask.setSession(this.baseUrl, this.session);
// Create a persistent notification for Hauk. This notification does have some
// buttons that let the user interact with Hauk while in the background, but the
// real reason we need a notification is so that Android does not kill our app while
// it is in the background. Having an active notification stops this from happening.
final SharingNotification notify = new SharingNotification(this, this.baseUrl, this.viewUrl, this.stopTask);
startForeground(notify.getID(), notify.create());
locMan.requestLocationUpdates(LocationManager.GPS_PROVIDER, this.interval, 0F, this);
}
} catch (Exception e) {
e.printStackTrace();
}
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
locMan.removeUpdates(this);
stopForeground(true);
super.onDestroy();
}
@Override
public void onLocationChanged(Location location) {
HashMap<String, String> data = new HashMap<>();
data.put("lat", String.valueOf(location.getLatitude()));
data.put("lon", String.valueOf(location.getLongitude()));
data.put("time", String.valueOf((double) System.currentTimeMillis() / 1000D));
data.put("sid", session);
if (location.hasSpeed()) data.put("spd", String.valueOf(location.getSpeed()));
if (location.hasAccuracy()) data.put("acc", String.valueOf(location.getAccuracy()));
HTTPThread req = new HTTPThread(new HTTPThread.Callback() {
@Override
public void run(HTTPThread.Response resp) {
// The response to this HTTP request can be ignored - there is no need for two-way
// communication in this case, as the pusher is only meant to push data.
}
});
req.execute(new HTTPThread.Request(this.baseUrl + "api/post.php", data));
}
@Override
public void onStatusChanged(String s, int i, Bundle bundle) {}
@Override
public void onProviderEnabled(String s) {}
@Override
public void onProviderDisabled(String s) {}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,166 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The main application activity for Hauk. -->
<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=".MainActivity">
<FrameLayout
android:layout_width="395dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Logo displayed at the top of the activity. -->
<ImageView
android:id="@+id/imgLogo"
android:layout_width="match_parent"
android:layout_height="143dp"
android:contentDescription="@string/img_alt_logo"
app:srcCompat="@drawable/ic_logo" />
<Space
android:layout_width="match_parent"
android:layout_height="19dp" />
<!-- Heading displayed underneath the Hauk logo. -->
<TextView
android:id="@+id/labelHeading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/label_heading"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<TextView
android:id="@+id/labelSourceLink"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/label_source_link" />
<Space
android:layout_width="match_parent"
android:layout_height="19dp" />
<!-- Connection preferences. -->
<TableLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- Server URL. -->
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/labelServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_server" />
<EditText
android:id="@+id/txtServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="textUri"
android:autofillHints="uri" />
</TableRow>
<!-- Server password. -->
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/labelPassword"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_password" />
<EditText
android:id="@+id/txtPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="textPassword"
android:autofillHints="password" />
</TableRow>
<!-- Share duration, in minutes. -->
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/labelDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_duration" />
<EditText
android:id="@+id/txtDuration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="number"
android:autofillHints="minutes" />
</TableRow>
<!-- Update interval, in seconds. -->
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/labelInterval"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_interval" />
<EditText
android:id="@+id/txtInterval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="number"
android:autofillHints="seconds" />
</TableRow>
</TableLayout>
<Space
android:layout_width="match_parent"
android:layout_height="25dp" />
<!-- The button that starts and stops the location sharing. -->
<Button
android:id="@+id/btnShare"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/btn_start"
android:onClick="startSharing" />
<!-- The button that shares the link. -->
<Button
android:id="@+id/btnLink"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/btn_link"
android:onClick="shareLink" />
</LinearLayout>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#D80037</color>
<color name="colorPrimaryDark">#000000</color>
<color name="colorAccent">#D80037</color>
</resources>

View file

@ -0,0 +1,42 @@
<resources>
<string name="app_name">Hauk</string>
<string name="img_alt_logo">Hauk</string>
<string name="label_heading">Open source location sharing</string>
<string name="label_source_link">https://github.com/bilde2910/Hauk</string>
<string name="label_server">Server URL:</string>
<string name="label_password">Password:</string>
<string name="label_duration">Share duration (min):</string>
<string name="label_interval">Update interval (s):</string>
<string name="btn_start">Start sharing</string>
<string name="btn_stop">Stop sharing</string>
<string name="btn_link">Share tracking link</string>
<string name="btn_ok">OK</string>
<string name="btn_cancel">Cancel</string>
<string name="action_copy">Copy link</string>
<string name="action_copied">Copied link from Hauk</string>
<string name="action_stop">Stop sharing</string>
<string name="notify_title">Location sharing active</string>
<string name="notify_body">Hauk is sharing your location to %s</string>
<string name="prog_title">Connecting</string>
<string name="prog_body">Connecting to Hauk...</string>
<string name="err_client">Invalid settings</string>
<string name="err_connect">Connection error</string>
<string name="err_server">Server error</string>
<string name="err_malformed_url">The server URL you entered is invalid.</string>
<string name="err_missing_perms">Location permission is required to use this app.</string>
<string name="err_location_disabled">Location services are disabled. Please enable high-accuracy location services to share your location.</string>
<string name="ok_title">Connection established</string>
<string name="ok_message">Location sharing is active! Click the share button to copy the publicly viewable URL for your share.</string>
<string name="ended_title">Sharing ended</string>
<string name="ended_message">Your location share has expired.</string>
<string name="req_perms_title">Permission required</string>
<string name="req_perms_message">This app requires access to your location to function, but this permission has not been granted yet. Please approve the following permission request, then click the start button again to retry.</string>
<string name="share_via">Share via</string>
<string name="share_subject">Follow my location on Hauk!</string>
</resources>

View file

@ -0,0 +1,10 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View file

@ -0,0 +1,17 @@
package info.varden.hauk;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

27
android/build.gradle Normal file
View file

@ -0,0 +1,27 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

20
android/gradle.properties Normal file
View file

@ -0,0 +1,20 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true

Binary file not shown.

View file

@ -0,0 +1,6 @@
#Wed Aug 21 10:50:30 CEST 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip

172
android/gradlew vendored Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
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=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
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
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
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=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
android/settings.gradle Normal file
View file

@ -0,0 +1 @@
include ':app'

40
backend/api/create.php Normal file
View file

@ -0,0 +1,40 @@
<?php
// This script handles session initiation for Hauk clients. It creates a session
// ID and associated share URL, and return these to the client.
foreach (array("pwd", "dur", "int") as $field) if (!isset($_POST[$field])) die("Missing data!\n");
// Verify that the client is authorized to connect.
include("../include/inc.php");
if (!password_verify($_POST["pwd"], CONFIG["password_hash"])) die("Incorrect password!\n");
// Perform input validation.
$d = intval($_POST["dur"]);
$i = floatval($_POST["int"]);
if ($d > CONFIG["max_duration"]) die("Share period is too long!\n");
if ($i > CONFIG["max_duration"]) die("Ping interval is too long!\n");
if ($i < CONFIG["min_interval"]) die("Ping interval is too short!\n");
$expire = time() + $d;
$memcache = memConnect();
// Create a session ID and verify that there is no colliding link ID.
$sid = "";
do $sid = bin2hex(openssl_random_pseudo_bytes(SESSION_ID_SIZE));
while ($memcache->get($PREFIX_LOCDATA.sessionToID($sid)) !== false);
$memcache->set($PREFIX_SESSION.$sid, json_encode(array(
"expire" => $expire,
"interval" => $i
)), 0, $d);
$memcache->set($PREFIX_LOCDATA.sessionToID($sid), json_encode(array(
"i" => $i,
"x" => $expire,
"l" => array()
)), 0, $d);
// Convert the session ID to a link ID and return these two to the client.
echo "OK\n{$sid}\n".CONFIG["public_url"]."?".sessionToID($sid)."\n";
?>

16
backend/api/fetch.php Normal file
View file

@ -0,0 +1,16 @@
<?php
// This script is called by the client to receive location updates. A link ID is
// required to retrieve data.
foreach (array("id") as $field) if (!isset($_GET[$field])) die("Invalid session!\n");
include("../include/inc.php");
$memcache = memConnect();
$locdata = $memcache->get($PREFIX_LOCDATA.$_GET["id"]);
// If the link data key is not set, the session probably expired.
if ($locdata === false) die("Invalid session!\n");
else { header("Content-Type: text/json"); echo $locdata; }
?>

46
backend/api/post.php Normal file
View file

@ -0,0 +1,46 @@
<?php
// This script is called from the Hauk app to push location updates to the
// server. Each update contains a location and timestamp from when the location
// was fetched by the client.
foreach (array("lat", "lon", "time", "sid") as $field) if (!isset($_POST[$field])) die("Missing data!");
// Perform input validation.
$lat = floatval($_POST["lat"]);
$lon = floatval($_POST["lon"]);
$time = floatval($_POST["time"]);
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) die("Invalid location!\n");
// Not all devices report speed and accuracy, but if available, report them too.
$speed = isset($_POST["spd"]) ? floatval($_POST["spd"]) : null;
$accuracy = isset($_POST["acc"]) ? floatval($_POST["acc"]) : null;
include("../include/inc.php");
$memcache = memConnect();
// Retrieve the session and associated location data from memcached.
$sid = $_POST["sid"];
$session = $memcache->get($PREFIX_SESSION.$sid);
$locdata = $memcache->get($PREFIX_LOCDATA.sessionToID($sid));
if ($session === false) die("Session expired!\n"); else $session = json_decode($session, 1);
if ($locdata === false) $locdata = ["l" => []]; else $locdata = json_decode($locdata, 1);
// The location data object contains the sharing interval (i), duration (d) and
// a location list (l). Each entry in the location list contains a latitude,
// longitude, timestamp, accuracy and speed, in that order, as an array.
$locdata["i"] = $session["interval"];
$locdata["x"] = $session["expire"];
$locdata["l"][] = [$lat, $lon, $time, $accuracy, $speed];
// Ensure that we don't exceed the maximum number of points stored in memcached.
while (count($locdata["l"]) > CONFIG["max_cached_pts"]) array_shift($locdata["l"]);
// Check if the session expired; otherwise, return the location data.
$remain = $session["expire"] - time();
if ($remain > 0) {
$memcache->replace($PREFIX_LOCDATA.sessionToID($sid), json_encode($locdata));
echo "OK\n";
} else echo "Session expired!\n";
?>

19
backend/api/stop.php Normal file
View file

@ -0,0 +1,19 @@
<?php
// This script handles session cancellation for Hauk clients. It removes the
// given session from memcached.
foreach (array("sid") as $field) if (!isset($_POST[$field])) die("Missing data!\n");
include("../include/inc.php");
$memcache = memConnect();
// Delete the session keys from memcached.
$sid = $_POST["sid"];
$session = $memcache->delete($PREFIX_SESSION.$sid);
$locdata = $memcache->delete($PREFIX_LOCDATA.sessionToID($sid));
// Convert the session ID to a link ID and return these two to the client.
echo "OK\n";
?>

BIN
backend/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

30
backend/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

17
backend/dynamic.js.php Normal file
View file

@ -0,0 +1,17 @@
<?php
// This script dynamically generates JavaScript containing certain configuration
// entries needed for clientside processing.
include("./include/inc.php");
header("Content-Type: text/javascript");
?>
var TILE_URI = <?php echo json_encode(CONFIG["map_tile_uri"]); ?>;
var ATTRIBUTION = <?php echo json_encode(CONFIG["map_attribution"]); ?>;
var DEFAULT_ZOOM = <?php echo json_encode(CONFIG["default_zoom"]); ?>;
var MAX_ZOOM = <?php echo json_encode(CONFIG["max_zoom"]); ?>;
var MAX_POINTS = <?php echo json_encode(CONFIG["max_shown_pts"]); ?>;
var VELOCITY_DELTA_TIME = <?php echo json_encode(CONFIG["v_data_points"]); ?>;
var TRAIL_COLOR = <?php echo json_encode(CONFIG["trail_color"]); ?>;
var VELOCITY_UNIT = <?php echo json_encode(CONFIG["velocity_unit"]); ?>;

View file

@ -0,0 +1,2 @@
# If running on Apache-like servers, deny browser access to this folder.
deny from all

View file

@ -0,0 +1,61 @@
<?php const CONFIG = array(
// Connection to memcached for data storage.
"memcached_host" => 'localhost',
"memcached_port" => 11211,
// A prefix to use for all variables sent to memcached. Useful if you have a
// shared memcached instance or run multiple instances of Hauk.
"memcached_prefix" => 'hauk',
// A hashed password that is required for creating sessions and posting location
// data to Hauk. To generate this value on the terminal:
// - MD5 (insecure!): openssl passwd -1
// - bcrypt (secure): htpasswd -nBC 10 "" | tail -c +2
"password_hash" => '$2y$10$4ZP1iY8A3dZygXoPgsXYV.S3gHzBbiT9nSfONjhWrvMxVPkcFq1Ka',
// Default value above is empty string (no password) and is VERY INSECURE.
// Trust me, you really should change this unless you intentionally want a
// public instance that anyone in the world can use freely.
// Leaflet tile URI template for the map frontend.
"map_tile_uri" => 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}.png?access_token=pk.eyJ1IjoiYmlsZGUyOTEwIiwiYSI6ImNqaXJmZzZmNTE1cnAzcXQ5cjBnZHB0OWgifQ.gib6DzyCRGN2PfyYJvZybA',
// Attribution HTML code to be displayed in the bottom right corner of the map.
// The default value is suitable for OpenStreetMap tiles.
"map_attribution" => 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>',
// Default and maximum zoom levels allowed on the map (0-20), higher value means
// closer zooming.
"default_zoom" => 20,
"max_zoom" => 20,
// Maximum duration of a single location share, in seconds.
"max_duration" => 86400,
// Minimum time between each location update, in seconds.
"min_interval" => 1,
// Maximum number of data points stored for each share before old points are
// deleted. Map clients will see up to this amount of data points when they load
// the page.
"max_cached_pts" => 3,
// Maximum number of data points that may be visible on the map at any time.
// This is used to draw trails behind the current location map marker. Higher
// values will show longer trails, but may reduce performance.
"max_shown_pts" => 100,
// Number of seconds of data that should be used to calculate velocity.
"v_data_points" => 2,
// The color of the marker trails. HTML color name or #rrggbb hex color code.
"trail_color" => '#d80037',
// The unit of measurement of velocity. Valid are:
// KILOMETERS_PER_HOUR, MILES_PER_HOUR, METERS_PER_SECOND
"velocity_unit" => KILOMETERS_PER_HOUR,
// The publicly accessible URL to reach Hauk, with trailing slash.
"public_url" => 'http://10.0.0.44/'
); ?>

64
backend/include/inc.php Normal file
View file

@ -0,0 +1,64 @@
<?php
// An include file containing constants and common functions for the Hauk
// backend. It loads the configuration file and declares it as a constant.
const SESSION_ID_SIZE = 32;
const EARTH_DIAMETER_KM = 6371 * 2;
const KILOMETERS_PER_HOUR = array(
// Relative distance per second multiplied by number of seconds per hour.
"havMod" => EARTH_DIAMETER_KM * 3600,
"mpsMod" => 3.6,
"unit" => "km/h"
);
const MILES_PER_HOUR = array(
// Same as for KILOMETERS_PER_HOUR, but convert kilometers to miles.
"havMod" => EARTH_DIAMETER_KM * 3600 * 0.6213712,
"mpsMod" => 3.6 * 0.6213712,
"unit" => "mph"
);
const METERS_PER_SECOND = array(
// Relative distance per second in kilometers, multiplied by meters per km.
"havMod" => EARTH_DIAMETER_KM * 1000,
"mpsMod" => 1,
"unit" => "m/s"
);
// Configuration can be stored either in /etc/hauk/config.php (e.g. Docker
// installations) or relative to this file as config.php. Only include the first
// one found from this list.
const CONFIG_PATHS = array(
"/etc/hauk/config.php",
__DIR__."/config.php"
);
foreach (CONFIG_PATHS as $path) {
if (file_exists($path)) {
include($path);
break;
}
}
if (!defined("CONFIG")) die("Unable to find config.php!\n");
$PREFIX_SESSION = CONFIG["memcached_prefix"]."-session-";
$PREFIX_LOCDATA = CONFIG["memcached_prefix"]."-locdata-";
// Function for converting session IDs to link IDs. The function is:
// First and last four digits of the base36 SHA256 sum of the binary session ID.
// This is converted to uppercase and the two parts separated by a dash.
function sessionToID($session) {
if (!preg_match("/^[0-9a-fA-F]{".(SESSION_ID_SIZE * 2)."}$/", $session)) return false;
$s = strtoupper(base_convert(hash("sha256", hex2bin($session)), 16, 36));
return substr($s, 0, 4)."-".substr($s, -4);
}
// Returns a memcached instance.
function memConnect() {
$memcache = new Memcache();
$memcache->connect(CONFIG["memcached_host"], CONFIG["memcached_port"])
or die ("Server could not connect to memcached!\n");
return $memcache;
}
?>

62
backend/index.html Normal file
View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<!-- Load Leaflet for the map. -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossorigin="" />
<script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js"
integrity="sha512-GffPMF3RvMeYyc1LWMHtK8EbPv0iNZ8/oTtHPx9/cc2ILxQ+u905qIwdpULaqDkyBKgOaB57QTMg7ztg8Jm2Og=="
crossorigin=""></script>
<link rel="stylesheet" href="./style.css" />
<link rel="icon" type="image/png" href="./assets/favicon.png">
<link rel="icon" type="image/svg+xml" href="./assets/favicon.svg">
<script src="./dynamic.js.php"></script>
<title>Hauk</title>
<!-- The page should not be scalable, since users can just zoom in on
the map itself directly. -->
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
</head>
<body>
<div id="mapouter">
<!-- The container for the Leaflet map. -->
<div id="map"></div>
<!-- The Hauk logo is displayed in the bottom left or right corner,
depending on screen resolution. -->
<a href="https://github.com/bilde2910/Hauk">
<div id="logo">
<div></div>
</div>
</a>
</div>
<!-- JavaScript is required for Leaflet and AJAX requests to the local
Hauk API. Inform the user if JavaScript is disabled. -->
<noscript>
<div class="cover">
<img src="./assets/logo.svg">
<p class="header">JavaScript disabled</p>
<p class="body">Hauk fundamentally requires JavaScript to function. Please enable JavaScript and reload the page to view the Hauk map.</p>
</div>
</noscript>
<div class="cover hidden" id="notfound">
<img src="./assets/logo.svg">
<p class="header">Location expired</p>
<p class="body">The shared location you tried to access was not found on the server. If this link worked before, the share might have expired.</p>
</div>
<div id="expired" class="dialog hidden">
<div>
<p class="header">Share expired</p>
<p class="body">This location share has expired.</p>
<p class="button"><input type="button" id="dismiss" value="Dismiss"></p>
</div>
</div>
<script src="./main.js"></script>
</body>
</html>

198
backend/main.js Normal file
View file

@ -0,0 +1,198 @@
// This is the main script file for Hauk's web view client.
// Create a Leaflet map.
var map = L.map('map').setView([0, 0], DEFAULT_ZOOM);
L.tileLayer(TILE_URI, {
attribution: ATTRIBUTION,
maxZoom: MAX_ZOOM
}).addTo(map);
var circleLayer = L.layerGroup().addTo(map);
var markerLayer = L.layerGroup().addTo(map);
// A list of points received from the server.
var points = [];
// The leaflet marker.
var marker = null;
var icon = null;
var circle = null;
// Retrieve the sharing link ID from the URL. E.g.
// https://example.com/?ABCD-1234 --> "ABCD-1234"
var id = location.href.substr(location.href.indexOf("?") + 1);
if (id.indexOf("&") !== -1) id = id.substr(0, id.indexOf("&"));
function getJSON(url, callback, invalid) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status === 200) {
try {
var json = JSON.parse(this.responseText);
callback(json);
} catch (ex) {
console.log(ex);
invalid();
}
}
}
xhr.send();
}
document.getElementById("dismiss").addEventListener("click", function() {
document.getElementById("expired").style.display = "none";
});
// Attempt to fetch location data from the server once.
getJSON("./api/fetch.php?id=" + id, function(data) {
document.getElementById("mapouter").style.visibility = "visible";
// The location data contains an interval. Schedule a task that fetches data
// once per interval time.
var interval = setInterval(function() {
// Stop the task if the share has expired.
if ((Date.now() / 1000) >= data.x) clearInterval(interval);
getJSON("./api/fetch.php?id=" + id, function(data) {
processUpdate(data);
}, function() {
clearInterval(interval);
document.getElementById("expired").style.display = "block";
});
}, data.i * 1000);
processUpdate(data);
}, function() {
document.getElementById("notfound").style.display = "block";
});
// Parses the data returned from ./api/fetch.php and updates the map marker.
function processUpdate(data) {
// Get the last location received.
var lastPoint = points.length > 0 ? points[points.length - 1] : null;
for (var i = 0; i < data.l.length; i++) {
var lat = data.l[i][0];
var lon = data.l[i][1];
var time = data.l[i][2];
var acc = data.l[i][3];
var spd = data.l[i][4];
// Check if the location should be added. Only add new location points
// if the point was not recorded before the last recorded point.
if (lastPoint === null || time > lastPoint.time) {
var line = null;
if (marker == null) {
// Add a marker to the map if it's not already there.
icon = L.divIcon({
html: '<div id="marker"><div id="arrow"></div><p><span id="velocity">0.0</span> ' + VELOCITY_UNIT.unit + '</p></div>',
iconAnchor: [33, 18]
});
marker = L.marker([lat, lon], {icon: icon, interactive: false}).addTo(markerLayer);
} else {
// If there is a marker, draw a line from its last location
// instead and move the marker.
line = L.polyline([marker.getLatLng(), [lat, lon]], {color: TRAIL_COLOR}).addTo(markerLayer);
marker.setLatLng([lat, lon]);
}
// Draw an accuracy circle if GPS accuracy was provided by the
// client.
if (acc !== null && circle == null) {
circle = L.circle([lat, lon], {radius: acc, fillColor: '#d80037', fillOpacity: 0.25, color: '#d80037', opacity: 0.5, interactive: false}).addTo(circleLayer);
} else if (circle !== null) {
circle.setLatLng([lat, lon]);
if (acc !== null) circle.setRadius(acc);
}
points.push({lat: lat, lon: lon, line: line, time: time, spd: spd, acc: acc});
lastPoint = points[points.length - 1];
}
}
var eVelocity = document.getElementById("velocity")
if (lastPoint !== null && lastPoint.spd !== null && eVelocity !== null) {
// Prefer client-provided speed if possible.
eVelocity.textContent = (lastPoint.spd * VELOCITY_UNIT.mpsMod).toFixed(1);
} else if (eVelocity !== null) {
// If the client didn't provide its speed, calculate it locally from its
// list of locations.
var dist = 0;
var time = 0;
var idx = points.length;
// Iterate over all locations backwards until we either reach our
// required VELOCITY_DELTA_TIME, or we run out of points.
while (idx > 2) {
idx--;
var pt1 = points[idx - 1];
var pt2 = points[idx];
var dTime = pt2.time - pt1.time;
// If the new time does not exceed the VELOCITY_DELTA_TIME, add the
// time and distance deltas to the appropriate sum for averaging;
// otherwise, break the loop and proceed to calculate.
if (time + dTime <= VELOCITY_DELTA_TIME) {
time += dTime;
dist += distance(pt1, pt2);
} else {
break;
}
}
// Update the UI with the velocity.
eVelocity.textContent = velocity(dist, time);
}
// Follow the marker.
if (lastPoint !== null) map.panTo([lastPoint.lat, lastPoint.lon]);
// Rotate the marker to the direction of movement.
var eArrow = document.getElementById("arrow");
if (eArrow !== null && points.length >= 2) {
var last = points.length - 1;
eArrow.style.transform = "rotate(" + angle(points[last - 1], points[last]) + "deg)";
}
// Prune the array of locations so it does not exceed our MAX_POINTS defined
// in the config.
if (points.length > MAX_POINTS) {
var remove = points.splice(0, points.length - MAX_POINTS);
for (var j = 0; j < remove.length; j++) if (remove[j].line !== null) map.removeLayer(remove[j].line);
}
}
// Calculates the distance between two points on a sphere using the Haversine
// algorithm.
function distance(from, to) {
var d2r = Math.PI / 180;
var fla = from.lat * d2r, tla = to.lat * d2r;
var flo = from.lon * d2r, tlo = to.lon * d2r;
var havLat = Math.sin((tla - fla) / 2); havLat *= havLat;
var havLon = Math.sin((tlo - flo) / 2); havLon *= havLon;
var hav = havLat + Math.cos(fla) * Math.cos(tla) * havLon;
var d = Math.asin(Math.sqrt(hav));
return d;
}
// Calculates a velocity using the velocity unit from the config.
function velocity(distance, intv) {
if (intv == 0) return "0.0";
return (distance * VELOCITY_UNIT.havMod / intv).toFixed(1);
}
// Calculates the bearing between two points on a sphere in degrees.
function angle(from, to) {
var d2r = Math.PI / 180;
var fromLat = from.lat * d2r, toLat = to.lat * d2r;
var fromLon = from.lon * d2r, toLon = to.lon * d2r;
/*
Calculation code by krishnar from
https://stackoverflow.com/a/52079217
*/
var x = Math.cos(fromLat) * Math.sin(toLat)
- Math.sin(fromLat) * Math.cos(toLat) * Math.cos(toLon - fromLon);
var y = Math.sin(toLon - fromLon) * Math.cos(toLat);
var heading = Math.atan2(y, x) / d2r;
return (heading + 360) % 360;
}

156
backend/style.css Normal file
View file

@ -0,0 +1,156 @@
/* The main stylesheet for the Hauk web view interface. */
* {
font-family: sans-serif;
}
.hidden {
display: none;
}
/* The map should cover the entire viewport. */
#mapouter {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
/* Hide by default while we check whether or not the share exists. */
visibility: hidden;
}
#map {
width: 100%;
height: 100%;
}
/* Popup covers that should display on top of the map. */
.cover {
position: absolute;
width: 80vmin;
height: 80vmin;
top: 0;
left: 50%;
z-index: 2000;
background-color: #fff;
text-align: center;
padding: 10vmin;
transform: translateX(-50%);
}
/* Hauk logo. */
.cover > img {
width: 100%;
}
/* Popup header. */
.cover > p.header {
font-size: 8vmin;
font-weight: bold;
}
/* Popup information. */
.cover > p.body {
font-size: 4vmin;
}
/* Dialog window that contains a title, message and button. This is the outer
box, with a semitransparent black background to provide shading against the
map, which it renders on top of. */
.dialog {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 2000;
background-color: rgba(0, 0, 0, 0.5);
}
/* The actual message dialog itself. */
.dialog > div {
width: 300px;
max-width: 80vw;
background-color: white;
padding: 5px 20px;
position: relative;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
.dialog p.header {
font-size: 1.2em;
font-weight: bold;
}
/* Ensure the button is big enough to be clicked on all devices. */
.dialog input[type=button] {
font-size: 1em;
}
/* Display the Hauk logo in the bottom left corner of the map if the viewport is
wide enough to accomodate it. If it's so narrow that the Leaflet attribution
could be covered by it, display it in the bottom left corner instead, above
the attribution. */
@media (max-width: 700px) {
#logo {
position: fixed;
bottom: 20px;
right: 5px;
z-index: 1000;
}
}
@media (min-width: 700.001px) {
#logo {
position: fixed;
bottom: 5px;
left: 10px;
z-index: 1000;
}
}
#logo div {
width: 73.33479px;
height: 28.853556px;
background: url(./assets/logo.svg) no-repeat;
background-size: cover;
margin: auto;
}
/* The outer marker div. */
#marker {
width: 66px;
height: 62px;
}
/* The arrow within the marker div. */
#arrow {
background: url(marker.svg) no-repeat;
background-size: cover;
width: 36px;
height: 36px;
margin: auto;
}
/* The velocity indicator on the marker div. */
#marker p {
font-size: 0.9em;
background-color: rgba(0,0,0,0.5);
color: white;
width: 100%;
border-radius: 15px;
text-align: center;
padding: 2px 0;
line-height: 100%;
font-family: sans-serif;
overflow: hidden;
white-space: nowrap;
text-overflow: clip;
}
/* Hide the default white box in the top left corner of the marker div. */
.leaflet-div-icon {
background: none;
border: none;
}