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
55 changes: 53 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,55 @@ 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];
response.setEntity(new StringEntity("sync_success"));
return;
}
}
}
}
//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";
Comment on lines +79 to +83

Choose a reason for hiding this comment

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

🔴 Backward-incompatible: older HearThis desktop without query params triggers false sync_error

When an older HearThis desktop (pre-HT-508) sends POST /notify HTTP/1.1 without any query parameters, the new AcceptNotificationHandler.handle() code sets status = "sync_error" and shows the user an error message even though sync actually completed successfully.

Detailed Explanation

The old AcceptNotificationHandler.handle() unconditionally called listener.onNotification("") and responded with "success" for all notifications. The old SyncActivity.onNotification() unconditionally showed "Sync completed successfully!".

With the new code at AcceptNotificationHandler.java:57-83:

  1. uri.getQuery() returns null for /notify (no query string)
  2. The for-loop is skipped entirely
  3. status remains null
  4. Line 81-83 sets status = "sync_error"
  5. Listeners receive "sync_error" → user sees "Sync timed out or had some other error. Please try again."

This means any user running the updated HearThisAndroid app with an older HearThis desktop will always see a sync error, even on a perfectly successful sync. The fallback for missing query parameters should be "sync_success" (or at minimum not "sync_error") to maintain backward compatibility.

Prompt for agents
In AcceptNotificationHandler.java lines 79-83, when status is null (no query parameters found), the code defaults to "sync_error". This breaks backward compatibility with older HearThis desktop versions that don't include query parameters in their notification. Consider checking whether the minHtaVersion has been set (indicating a new HT desktop that supports the protocol) before defaulting to error. If minHtaVersion is null (old desktop), status should default to "sync_success" to preserve the previous behavior. If minHtaVersion is set but status is missing, then "sync_error" is appropriate. For example:

if (status == null) {
    if (minHtaVersion != null) {
        status = "sync_error";
    } else {
        status = "sync_success";
    }
}
Open in Devin Review

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

}

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
125 changes: 91 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,50 @@ 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();
String ipAddress = ipView.getText().toString();
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
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);
socket.close();

// We 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");
}
});
//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.
});
});
executor.shutdown();
cameraSource.stop();
cameraSource.release();
cameraSource = null;
}
});

}
}
}
Expand Down Expand Up @@ -216,11 +263,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 +292,35 @@ 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.
if (watchdog != null) {
watchdog.shutdown();
}

// 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 +342,10 @@ public void run() {

@Override
public void receivingFile(final String name) {
if (watchdog != null) {
watchdog.pet();
}

// 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 +356,13 @@ public void receivingFile(final String name) {

@Override
public void sendingFile(final String name) {
if (watchdog != null) {
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
Loading