diff --git a/app/src/main/java/org/sil/hearthis/AcceptFileHandler.java b/app/src/main/java/org/sil/hearthis/AcceptFileHandler.java index 96f0cd8..1c9d304 100644 --- a/app/src/main/java/org/sil/hearthis/AcceptFileHandler.java +++ b/app/src/main/java/org/sil/hearthis/AcceptFileHandler.java @@ -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; @@ -32,8 +33,10 @@ 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) { + Log.d("Sync", "AcceptFileHandler, calling listener.receivingFile(" + filePath + ")"); listener.receivingFile(filePath); + } String path = baseDir + "/" + filePath; HttpEntity entity = null; String result = "failure"; diff --git a/app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java b/app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java index b9b3250..31625dc 100644 --- a/app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java +++ b/app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java @@ -1,5 +1,7 @@ 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; @@ -7,6 +9,7 @@ import org.apache.http.protocol.HttpContext; import org.apache.http.protocol.HttpRequestHandler; import java.io.IOException; +import java.net.URI; import java.util.ArrayList; /** @@ -14,6 +17,8 @@ */ public class AcceptNotificationHandler implements HttpRequestHandler { + private static String minHtaVersion = null; + public interface NotificationListener { void onNotification(String message); } @@ -32,9 +37,60 @@ 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. + + String status = null; + Log.d("Sync", "handle, begin, minHtaVersion = " + minHtaVersion); // WM, TEMPORARY + try { + String s1 = request.getRequestLine().getUri(); + URI uri = new URI(s1); + String query = uri.getQuery(); + Log.d("Sync", "handle, query = " + query); // WM, TEMPORARY + 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]; + Log.d("Sync", "handle, status = " + status); // WM, TEMPORARY + } else if (pair[0].equals("minHtaVersion")) { + minHtaVersion = pair[1]; + Log.d("Sync", "handle, minHtaVersion = " + minHtaVersion + ", returning"); // WM, TEMPORARY + return; + } + } + } + } + Log.d("Sync", "handle, results: status = " + status + ", minHtaVersion = " + minHtaVersion); // WM, TEMPORARY + } 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"; + } + + Log.d("Sync", "handle, final from HT, status = " + status); // WM, TEMPORARY for (NotificationListener listener: notificationListeners.toArray(new NotificationListener[notificationListeners.size()])) { - listener.onNotification(""); + Log.d("Sync", "handle, calling listener.onNotification(" + status + ")"); // WM, TEMPORARY + listener.onNotification(status); } - response.setEntity(new StringEntity("success")); + Log.d("Sync", "handle, putting status in response (I think)"); // WM, TEMPORARY + response.setEntity(new StringEntity(status)); } } diff --git a/app/src/main/java/org/sil/hearthis/RequestFileHandler.java b/app/src/main/java/org/sil/hearthis/RequestFileHandler.java index a5bae8a..81055db 100644 --- a/app/src/main/java/org/sil/hearthis/RequestFileHandler.java +++ b/app/src/main/java/org/sil/hearthis/RequestFileHandler.java @@ -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; @@ -29,8 +30,10 @@ 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) { + Log.d("Sync", "RequestFileHandler, calling listener.sendingFile(" + filePath + ")"); listener.sendingFile(filePath); + } String path = baseDir + "/" + filePath; File file = new File(path); if (!file.exists()) { diff --git a/app/src/main/java/org/sil/hearthis/SyncActivity.java b/app/src/main/java/org/sil/hearthis/SyncActivity.java index 2f8bbed..b2de3d8 100644 --- a/app/src/main/java/org/sil/hearthis/SyncActivity.java +++ b/app/src/main/java/org/sil/hearthis/SyncActivity.java @@ -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; @@ -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, @@ -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; boolean scanning = false; TextView progressView; - private BarcodeDetector barcodeDetector; private CameraSource cameraSource; + private Watchdog watchdog; @Override protected void onCreate(Bundle savedInstanceState) { @@ -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 detections) { final SparseArray barcodes = detections.getDetectedItems(); @@ -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(); + 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); // WM, TEMPORARY + 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); + Log.d("Sync", "SyncActivity.run, sending UDP packet"); // WM, TEMPORARY + //throw new IOException("TEST HACK"); // WM, test only! + socket.send(packet); // WM, comment out if preceding throw(), a hack, is present + + // 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 mitigation code, running only if timeout occurs. + watchdog = new Watchdog(WATCHDOG_TIMEOUT_SECONDS, TimeUnit.SECONDS, () -> { + Log.d("Sync", "Watchdog, TIMED OUT, setting Error"); + 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"); + } 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_interrupted"); + } + Log.d("Sync", "SyncActivity.run, got exception: " + ioe); + ioe.printStackTrace(); + } + handler.post(() -> { + // Background work done, no associated foreground work needed. + }); + }); cameraSource.stop(); cameraSource.release(); cameraSource = null; } }); - } } } @@ -176,6 +223,7 @@ public void run() { String ourIpAddress = getOurIpAddress(); TextView ourIpView = (TextView) findViewById(R.id.our_ip_address); ourIpView.setText(ourIpAddress); + Log.d("Sync", "onCreateOptionsMenu, calling addNotificationListener()"); // WM, TEMPORARY AcceptNotificationHandler.addNotificationListener(this); return true; } @@ -216,11 +264,8 @@ private String getOurIpAddress() { if (inetAddress.isSiteLocalAddress()) { return inetAddress.getHostAddress(); } - } - } - } catch (SocketException e) { // TODO Auto-generated catch block e.printStackTrace(); @@ -247,8 +292,36 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override public void onNotification(String message) { + Log.d("Sync", "onNotification(" + message + "), calling removeNotificationListener()"); // WM, TEMPORARY 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. + Log.d("Sync", "onNotification, got " + message + ", shutting down watchdog"); // WM, temporary + 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_interrupted": + // Sync was interrupted or cancelled. + setProgress(getString(R.string.sync_interrupted)); + 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); + break; + } runOnUiThread(new Runnable() { @Override public void run() { @@ -270,42 +343,27 @@ public void run() { @Override public void receivingFile(final String name) { + Log.d("Sync", " receivingFile, pet watchdog"); // WM, temporary + 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) return; lastProgress = new Date(); setProgress("receiving " + name); + Log.d("Sync", "receivingFile: " + name); // WM, temporary } @Override public void sendingFile(final String name) { + Log.d("Sync", " sendingFile, pet watchdog"); // WM, temporary + 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 { - - 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; - } + Log.d("Sync", "sendingFile: " + name); // WM, temporary } } diff --git a/app/src/main/java/org/sil/hearthis/SyncServer.java b/app/src/main/java/org/sil/hearthis/SyncServer.java index f97e21e..b865c83 100644 --- a/app/src/main/java/org/sil/hearthis/SyncServer.java +++ b/app/src/main/java/org/sil/hearthis/SyncServer.java @@ -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; @@ -94,9 +96,16 @@ public void run() { DefaultHttpServerConnection serverConnection = new DefaultHttpServerConnection(); serverConnection.bind(socket, new BasicHttpParams()); + //Log.d("Sync", "SyncServer.run, new serverConnection, timeout = " + serverConnection.getSocketTimeout()); // WM, TEMPORARY + //Log.d("Sync", "SyncServer.run, calling handleRequest()"); // WM, TEMPORARY + // Set a timeout so that HTA doesn't get stuck if HT can't finish a sync. + //serverConnection.setSocketTimeout(5000); // what's optimum? try 5 secs... + //Log.d("Sync", " new timeout = " + serverConnection.getSocketTimeout()); // WM, TEMPORARY httpService.handleRequest(serverConnection, httpContext); + //Log.d("Sync", "SyncServer.run, handleRequest done, calling serverConnection.shutdown()"); // WM, TEMPORARY + //Log.d("Sync", " "); // WM, TEMPORARY serverConnection.shutdown(); } catch (IOException e) { e.printStackTrace(); diff --git a/app/src/main/java/org/sil/hearthis/Watchdog.java b/app/src/main/java/org/sil/hearthis/Watchdog.java new file mode 100644 index 0000000..d3e12de --- /dev/null +++ b/app/src/main/java/org/sil/hearthis/Watchdog.java @@ -0,0 +1,49 @@ +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) { + //Log.d("Sync", "Watchdog, constructor, timeout = " + timeout); // WM, temporary + //Log.d("Sync", " unit = " + unit); // WM, temporary + this.timeout = timeout; + this.unit = unit; + this.onTimeout = onTimeout; + } + + // 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()) { + Log.d("Sync", "Watchdog, pet, not null and not done"); // WM, temporary + watchdogTask.cancel(false); + } + //Log.d("Sync", "Watchdog, pet, calling scheduler.schedule()"); // WM, temporary + watchdogTask = scheduler.schedule(onTimeout, timeout, unit); + } + + public void shutdown() { + Log.d("Sync", "Watchdog, shutting down"); // WM, temporary + scheduler.shutdownNow(); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 471d647..ba9b046 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Continue ChooseBookActivity Sync completed successfully! + Sync was interrupted or cancelled + Sync had an error. Please try again. Choose a project Choose a book Choose a chapter