diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3a2ecdb..0000000 --- a/.gitignore +++ /dev/null @@ -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/ diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..2b75303 --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/.idea/encodings.xml b/android/.idea/encodings.xml new file mode 100644 index 0000000..15a15b2 --- /dev/null +++ b/android/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml new file mode 100644 index 0000000..2996d53 --- /dev/null +++ b/android/.idea/gradle.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 0000000..37a7509 --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/android/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml new file mode 100644 index 0000000..54e4b96 --- /dev/null +++ b/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..a0d3ee7 --- /dev/null +++ b/android/app/build.gradle @@ -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' +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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 diff --git a/android/app/src/androidTest/java/info/varden/hauk/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/info/varden/hauk/ExampleInstrumentedTest.java new file mode 100644 index 0000000..b95d8fe --- /dev/null +++ b/android/app/src/androidTest/java/info/varden/hauk/ExampleInstrumentedTest.java @@ -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 Testing documentation + */ +@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()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..529d5fe --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/info/varden/hauk/DialogService.java b/android/app/src/main/java/info/varden/hauk/DialogService.java new file mode 100644 index 0000000..799e3c0 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/DialogService.java @@ -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(); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/HTTPThread.java b/android/app/src/main/java/info/varden/hauk/HTTPThread.java new file mode 100644 index 0000000..a4df426 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/HTTPThread.java @@ -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 { + + // 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 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 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 data; + + public Request(String url, Map 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); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/MainActivity.java b/android/app/src/main/java/info/varden/hauk/MainActivity.java new file mode 100644 index 0000000..86f39b7 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/MainActivity.java @@ -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 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(); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/ReceiverDataRegistry.java b/android/app/src/main/java/info/varden/hauk/ReceiverDataRegistry.java new file mode 100644 index 0000000..cf68600 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/ReceiverDataRegistry.java @@ -0,0 +1,19 @@ +package info.varden.hauk; + +import java.util.HashMap; +import java.util.Random; + +public class ReceiverDataRegistry { + private static HashMap 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); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/StopSharingTask.java b/android/app/src/main/java/info/varden/hauk/StopSharingTask.java new file mode 100644 index 0000000..4b8d028 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/StopSharingTask.java @@ -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 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); + } + } +} diff --git a/android/app/src/main/java/info/varden/hauk/notify/CopyLinkReceiver.java b/android/app/src/main/java/info/varden/hauk/notify/CopyLinkReceiver.java new file mode 100644 index 0000000..457725d --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/notify/CopyLinkReceiver.java @@ -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 { + + 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); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/notify/HaukBroadcastReceiver.java b/android/app/src/main/java/info/varden/hauk/notify/HaukBroadcastReceiver.java new file mode 100644 index 0000000..ab86db8 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/notify/HaukBroadcastReceiver.java @@ -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 The type of data this broadcast receiver is capable of processing. + */ +public abstract class HaukBroadcastReceiver 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)); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/notify/HaukNotification.java b/android/app/src/main/java/info/varden/hauk/notify/HaukNotification.java new file mode 100644 index 0000000..0edff41 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/notify/HaukNotification.java @@ -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(); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/notify/Receiver.java b/android/app/src/main/java/info/varden/hauk/notify/Receiver.java new file mode 100644 index 0000000..571fef0 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/notify/Receiver.java @@ -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 The type of data to be passed to the receiving listener. + */ +public class Receiver { + private final Class> 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> 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 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); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/notify/SharingNotification.java b/android/app/src/main/java/info/varden/hauk/notify/SharingNotification.java new file mode 100644 index 0000000..c03bec8 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/notify/SharingNotification.java @@ -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); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/notify/StopSharingReceiver.java b/android/app/src/main/java/info/varden/hauk/notify/StopSharingReceiver.java new file mode 100644 index 0000000..965761d --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/notify/StopSharingReceiver.java @@ -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 { + + 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(); + } +} diff --git a/android/app/src/main/java/info/varden/hauk/service/LocationPushService.java b/android/app/src/main/java/info/varden/hauk/service/LocationPushService.java new file mode 100644 index 0000000..9912830 --- /dev/null +++ b/android/app/src/main/java/info/varden/hauk/service/LocationPushService.java @@ -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 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; + } +} diff --git a/android/app/src/main/res/drawable/ic_icon.xml b/android/app/src/main/res/drawable/ic_icon.xml new file mode 100644 index 0000000..e443185 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_icon.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_logo.xml b/android/app/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000..d5cc45b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_notify.xml b/android/app/src/main/res/drawable/ic_notify.xml new file mode 100644 index 0000000..fc8cca6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notify.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..067c2a0 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +