Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/java/org/sil/hearthis/AcceptFileHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import android.content.Context;
import android.net.Uri;
import android.util.Log;

import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
Expand Down Expand Up @@ -32,8 +33,9 @@ public void handle(HttpRequest request, HttpResponse response, HttpContext httpC
File baseDir = _parent.getExternalFilesDir(null);
Uri uri = Uri.parse(request.getRequestLine().getUri());
String filePath = uri.getQueryParameter("path");
if (listener != null)
if (listener != null) {
listener.receivingFile(filePath);
}
String path = baseDir + "/" + filePath;
HttpEntity entity = null;
String result = "failure";
Expand Down
54 changes: 52 additions & 2 deletions app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package org.sil.hearthis;
import android.content.Context;
import android.util.Log; // WM, TEMPORARY!

import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.entity.StringEntity;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;

/**
* Created by Thomson on 1/18/2016.
*/
public class AcceptNotificationHandler implements HttpRequestHandler {

private static String minHtaVersion = null;

public interface NotificationListener {
void onNotification(String message);
}
Expand All @@ -32,9 +37,54 @@ public void handle(HttpRequest request, HttpResponse response, HttpContext httpC

// Enhance: allow the notification to contain a message, and pass it on.
// The copy is made because the onNotification calls may well remove listeners, leading to concurrent modification exceptions.

// HT-508: to prevent HTA from getting stuck in a bad state when sync is interrupted,
// extract and handle sync status that HT inserted into the notification. HT also sets up
// that notification by first sending a notification containing the minimum HTA version
// needed for this exchange.
// The notifications received from the HearThis PC are HttpRequest (RFC 7230), like this:
// POST /notify?minHtaVersion=1.0 HTTP/1.1 -- HT sends this first
// POST /notify?status=sync_success HTTP/1.1 -- HT sends this second
// Payload is in the portion after the 'notify'. Extract it and send it along.
// If something goes wrong and that is not possible, send along an error indication.
// NOTIFICATION ORDER IS IMPORTANT. HT must send the HTA version info first, and then the
// sync final status. This is enforced by an early return when the HTA version info is seen.
//
// NOTE: like several things in HearThisAndroid, HttpRequest is deprecated. It will be
// replaced with something more appropriate, hopefully soon. When that happens this logic
// will most likely also change.

String status = null;
try {
String s1 = request.getRequestLine().getUri();
URI uri = new URI(s1);
String query = uri.getQuery();
if (query != null) {
for (String param : query.split("&")) {
String[] pair = param.split("=", 2); // limit=2 in case value contains '='
if (pair.length == 2) {
if (pair[0].equals("status")) {
status = pair[1];
} else if (pair[0].equals("minHtaVersion")) {
minHtaVersion = pair[1];
return;
Comment on lines +68 to +70

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 AcceptNotificationHandler early return for minHtaVersion sends no HTTP response

At AcceptNotificationHandler.java:70, when the handler receives a minHtaVersion query parameter, it does return without setting any response entity on the HttpResponse object.

Root Cause and Impact

When HearThis PC sends POST /notify?minHtaVersion=1.0 HTTP/1.1, the handler stores the version and returns immediately at line 70 without calling response.setEntity(...). The other code paths (lines 85-88) always set a response entity. The HTTP server (HttpService.handleRequest) will send back a response with no body, which may cause the HearThis PC client to fail or behave unexpectedly if it expects a response body. At minimum, a response entity should be set before returning to maintain the API contract.

Suggested change
} else if (pair[0].equals("minHtaVersion")) {
minHtaVersion = pair[1];
return;
} else if (pair[0].equals("minHtaVersion")) {
minHtaVersion = pair[1];
response.setEntity(new StringEntity("ok"));
return;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}
}
}
//Log.d("Sync", "handle, results: status = " + status + ", minHtaVersion = " + minHtaVersion); // implement for tech support
} catch (Exception e) {
e.printStackTrace();
}

if (status == null) {
// We got something but it wasn't "status". Make sure the user sees an error message.
status = "sync_error";
}

for (NotificationListener listener: notificationListeners.toArray(new NotificationListener[notificationListeners.size()])) {
listener.onNotification("");
listener.onNotification(status);
}
response.setEntity(new StringEntity("success"));
response.setEntity(new StringEntity(status));
}
}
4 changes: 3 additions & 1 deletion app/src/main/java/org/sil/hearthis/RequestFileHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import android.content.Context;
import android.net.Uri;
import android.util.Log;

import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
Expand Down Expand Up @@ -29,8 +30,9 @@ public void handle(HttpRequest request, HttpResponse response, HttpContext httpC
File baseDir = _parent.getExternalFilesDir(null);
Uri uri = Uri.parse(request.getRequestLine().getUri());
String filePath = uri.getQueryParameter("path");
if (listener!= null)
if (listener!= null) {
listener.sendingFile(filePath);
}
String path = baseDir + "/" + filePath;
File file = new File(path);
if (!file.exists()) {
Expand Down
118 changes: 84 additions & 34 deletions app/src/main/java/org/sil/hearthis/SyncActivity.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package org.sil.hearthis;

import static org.sil.hearthis.AcceptNotificationHandler.notificationListeners;

import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;

import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.util.SparseArray;
import android.view.Menu;
import android.view.MenuItem;
Expand All @@ -23,15 +27,20 @@
import com.google.android.gms.vision.barcode.Barcode;
import com.google.android.gms.vision.barcode.BarcodeDetector;

//import org.apache.http.entity.StringEntity;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
//import java.net.UnknownHostException;
import java.util.Date;
import java.util.Enumeration;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;


public class SyncActivity extends AppCompatActivity implements AcceptNotificationHandler.NotificationListener,
Expand All @@ -44,11 +53,12 @@ public class SyncActivity extends AppCompatActivity implements AcceptNotificatio
SurfaceView preview;
int desktopPort = 11007; // port on which the desktop is listening for our IP address.
private static final int REQUEST_CAMERA_PERMISSION = 201;
private static final int WATCHDOG_TIMEOUT_SECONDS = 10; // match the HearThis timeout?
boolean scanning = false;
TextView progressView;

private BarcodeDetector barcodeDetector;
private CameraSource cameraSource;
private Watchdog watchdog;

@Override
protected void onCreate(Bundle savedInstanceState) {
Expand Down Expand Up @@ -125,6 +135,8 @@ public void release() {
// Toast.makeText(getApplicationContext(), "To prevent memory leaks barcode scanner has been stopped", Toast.LENGTH_SHORT).show();
}

// Replacing 'AsyncTask' (deprecated) with 'Executors' and 'Handlers' in this method is inspired by:
// https://stackoverflow.com/questions/58767733/the-asynctask-api-is-deprecated-in-android-11-what-are-the-alternatives
@Override
public void receiveDetections(Detector.Detections<Barcode> detections) {
final SparseArray<Barcode> barcodes = detections.getDetectedItems();
Expand All @@ -145,15 +157,49 @@ public void run() {
// provide some users a clue that all is not well.
ipView.setText(contents);
preview.setVisibility(View.INVISIBLE);
SendMessage sendMessageTask = new SendMessage();
sendMessageTask.ourIpAddress = getOurIpAddress();
sendMessageTask.execute();
ExecutorService executor = Executors.newSingleThreadExecutor();
Handler handler = new Handler(Looper.getMainLooper());
executor.execute(() -> {
// Background work: send UDP packet to IP address given in the QR code.
try {
String ourIpAddress = getOurIpAddress();
//Log.d("Sync", "SyncActivity.run, ourIpAddress = " + ourIpAddress); // implement for tech support
String ipAddress = ipView.getText().toString();
InetAddress receiverAddress = InetAddress.getByName(ipAddress);
DatagramSocket socket = new DatagramSocket();
byte[] ipBytes = ourIpAddress.getBytes("UTF-8");
DatagramPacket packet = new DatagramPacket(ipBytes, ipBytes.length, receiverAddress, desktopPort);
socket.send(packet);

// Don't create and start the watchdog until we KNOW that we are doing a sync.
// At this point we have responded to the PC's sync offer and are indeed committed.
// NOTE: inside the braces is the 'onTimeout' mitigation code, running only if
// timeout occurs.
watchdog = new Watchdog(WATCHDOG_TIMEOUT_SECONDS, TimeUnit.SECONDS, () -> {
//Log.d("Sync", "Watchdog, TIMED OUT, setting Error"); // implement for tech support
for (AcceptNotificationHandler.NotificationListener listener: notificationListeners.toArray(new AcceptNotificationHandler.NotificationListener[notificationListeners.size()])) {
listener.onNotification("sync_error");
}
setProgress(getString(R.string.sync_error));
});
//Log.d("Sync", "SyncActivity.run, watchdog started, timeout = " + WATCHDOG_TIMEOUT_SECONDS + " secs"); // implement for tech support
} catch (IOException ioe) {
// Note: this also catches UnknownHostException, a subclass of IOException
for (AcceptNotificationHandler.NotificationListener listener : notificationListeners.toArray(new AcceptNotificationHandler.NotificationListener[notificationListeners.size()])) {
listener.onNotification("sync_canceled");
}
//Log.d("Sync", "SyncActivity.run, got exception: " + ioe); // implement for tech support
ioe.printStackTrace();
}
handler.post(() -> {
// Background work done, no associated foreground work needed.
});
});
cameraSource.stop();
cameraSource.release();
cameraSource = null;
}
});

}
}
}
Expand Down Expand Up @@ -216,11 +262,8 @@ private String getOurIpAddress() {
if (inetAddress.isSiteLocalAddress()) {
return inetAddress.getHostAddress();
}

}

}

} catch (SocketException e) {
// TODO Auto-generated catch block
e.printStackTrace();
Expand Down Expand Up @@ -248,7 +291,33 @@ public boolean onOptionsItemSelected(MenuItem item) {
@Override
public void onNotification(String message) {
AcceptNotificationHandler.removeNotificationListener(this);
setProgress(getString(R.string.sync_success));

// The watchdog timer prevents the Android app from getting stuck if the PC side
// is unable to complete a sync operation. Getting here means we got a notification
// from the PC. It should contain the final sync status, but even if it doesn't, the
// sync operation *is* complete and the watchdog should be turned off.
watchdog.shutdown();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 NullPointerException on watchdog.shutdown() when watchdog has not been initialized

At SyncActivity.java:299, watchdog.shutdown() is called unconditionally in onNotification(), but watchdog may be null if the notification arrives before the QR code scanning and executor task assigns the watchdog field.

Root Cause and Impact

The watchdog field is only assigned inside a background executor task at line 178, which runs after a QR code is scanned. However, onNotification() can be called in several scenarios where watchdog is still null:

  1. If the AcceptNotificationHandler receives a notification with a status parameter from the PC before the user has scanned a QR code (e.g., from a stale/leftover request).
  2. If the old HearThis PC (without the new minHtaVersion/status protocol) sends a plain /notify request — AcceptNotificationHandler will set status = "sync_error" and call onNotification("sync_error"), which reaches SyncActivity.onNotification() while watchdog is still null.

In any of these cases, watchdog.shutdown() at line 299 will throw a NullPointerException, crashing the app.

Suggested change
watchdog.shutdown();
if (watchdog != null) {
watchdog.shutdown();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// HT-508: HearThis PC now includes sync status in its notification to the app.
// We can now inform the user about whether sync succeeded.
switch (message) {
case "sync_success":
setProgress(getString(R.string.sync_success));
break;
case "sync_canceled":
// Sync was canceled.
setProgress(getString(R.string.sync_canceled));
break;
case "sync_error":
// Internal HTA error or incompatible versions of HT and HTA.
setProgress(getString(R.string.sync_error));
break;
default:
// Not a sync status; should never happen. Raise an error.
setProgress(getString(R.string.sync_error));
//Log.d("Sync", "onNotification.default, bad status: " + message); // implement for tech support
break;
}
runOnUiThread(new Runnable() {
@Override
public void run() {
Expand All @@ -270,6 +339,8 @@ public void run() {

@Override
public void receivingFile(final String name) {
watchdog.pet();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 NullPointerException on watchdog.pet() when watchdog has not been initialized

At SyncActivity.java:342 and SyncActivity.java:354, watchdog.pet() is called unconditionally in receivingFile() and sendingFile(). However, watchdog can be null if file transfer callbacks arrive before the watchdog is created.

Root Cause and Impact

The watchdog field is assigned inside a background executor task at line 178. The receivingFile() and sendingFile() callbacks are triggered by the HTTP server handlers (AcceptFileHandler and RequestFileHandler) which use a static listener field set in onResume() at lines 91-92. Since the listener is registered in onResume() (which runs before any QR code scan), it's possible for file transfer requests to arrive and trigger these callbacks before the watchdog is initialized — for example, if the PC initiates file transfers from a previous session or if there's a race between the executor creating the watchdog and the server receiving file requests.

Calling pet() on a null watchdog will throw a NullPointerException, crashing the app.

Suggested change
watchdog.pet();
if (watchdog != null) {
watchdog.pet();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// To prevent excess flicker and wasting compute time on progress reports,
// only change once per second.
if (new Date().getTime() - lastProgress.getTime() < 1000)
Expand All @@ -280,32 +351,11 @@ public void receivingFile(final String name) {

@Override
public void sendingFile(final String name) {
watchdog.pet();

if (new Date().getTime() - lastProgress.getTime() < 1000)
return;
lastProgress = new Date();
setProgress("sending " + name);
}

// This class is responsible to send one message packet to the IP address we
// obtained from the desktop, containing the Android's own IP address.
private class SendMessage extends AsyncTask<Void, Void, Void> {

public String ourIpAddress;
@Override
protected Void doInBackground(Void... params) {
try {
String ipAddress = ipView.getText().toString();
InetAddress receiverAddress = InetAddress.getByName(ipAddress);
DatagramSocket socket = new DatagramSocket();
byte[] buffer = ourIpAddress.getBytes("UTF-8");
DatagramPacket packet = new DatagramPacket(buffer, buffer.length, receiverAddress, desktopPort);
socket.send(packet);
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/org/sil/hearthis/SyncServer.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.sil.hearthis;

import android.util.Log;

import org.apache.http.HttpException;
import org.apache.http.impl.DefaultConnectionReuseStrategy;
import org.apache.http.impl.DefaultHttpResponseFactory;
Expand Down Expand Up @@ -94,9 +96,7 @@ public void run() {
DefaultHttpServerConnection serverConnection = new DefaultHttpServerConnection();

serverConnection.bind(socket, new BasicHttpParams());

httpService.handleRequest(serverConnection, httpContext);

serverConnection.shutdown();
} catch (IOException e) {
e.printStackTrace();
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/org/sil/hearthis/Watchdog.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.sil.hearthis;

import java.util.concurrent.*;
import android.util.Log;

/**
* This class implements a "watchdog" timer for the Android side of a HearThis sync operation.
*
* Once instantiated and started, it counts down from its timeout value (passed in). The timer
* is NOT supposed to get all the way down to 0. If it does, a problematic condition has arisen
* somewhere and the 'onTimeout' code runs in an effort to mitigate the problem.
* Calling pet() restarts a full countdown. The timeout value should be chosen such that it is
* longer than any normal interval between calls to pet(). Thus in a correctly working system,
* pet() keeps getting called well before the timer ever finishes counting down to 0 from its
* initial timeout value, and the 'onTimeout' code never runs.
*/

public class Watchdog {
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private ScheduledFuture<?> watchdogTask;
private final Runnable onTimeout;
private final long timeout;
private final TimeUnit unit;

public Watchdog(long timeout, TimeUnit unit, Runnable onTimeout) {
this.timeout = timeout;
this.unit = unit;
this.onTimeout = onTimeout;
}
Comment on lines +25 to +29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Watchdog timer never starts its initial countdown after construction

The Watchdog is constructed at SyncActivity.java:178 but pet() is never called to kick off the initial timer countdown. The Watchdog constructor only stores parameters — it does not schedule anything. The pet() method is the only way to start the timer, and it is only called from receivingFile() (line 342) and sendingFile() (line 354).

Root Cause and Impact

If the sync operation stalls before any files are transferred (e.g., the PC receives the UDP packet but fails before sending/requesting any files), neither receivingFile() nor sendingFile() will ever be called, so pet() is never invoked, the watchdog timer never starts, and the timeout callback never fires. The app will be stuck indefinitely with no error message and no way for the user to proceed (the Continue button is never enabled).

The Watchdog's own documentation says "Once instantiated and started, it counts down from its timeout value" but the constructor doesn't start anything. A pet() call is needed right after construction at line 184 to start the initial countdown.

Prompt for agents
In app/src/main/java/org/sil/hearthis/SyncActivity.java, after the Watchdog is constructed at line 178-184, add a call to watchdog.pet() to start the initial countdown timer. This should be placed right after the Watchdog constructor call (around line 184, after the closing parenthesis and semicolon of the Watchdog construction). Alternatively, modify the Watchdog constructor in app/src/main/java/org/sil/hearthis/Watchdog.java at lines 25-29 to automatically schedule the first timeout by calling pet() at the end of the constructor.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Subsystems of interest call this method to restart the timer countdown. Basically this
// means: "At the moment all is well. We'll try to call again before your next deadline. If
// we don't, send for help."
public synchronized void pet() {
if (watchdogTask != null && !watchdogTask.isDone()) {
watchdogTask.cancel(false);
}
watchdogTask = scheduler.schedule(onTimeout, timeout, unit);
}

public void shutdown() {
scheduler.shutdownNow();
}
}
Loading