diff --git a/app/src/main/java/org/sil/hearthis/AcceptFileHandler.java b/app/src/main/java/org/sil/hearthis/AcceptFileHandler.java index 96f0cd8..6c14b77 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,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"; diff --git a/app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java b/app/src/main/java/org/sil/hearthis/AcceptNotificationHandler.java index b9b3250..12f24bc 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,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; + } + } + } + } + //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)); } } diff --git a/app/src/main/java/org/sil/hearthis/RequestFileHandler.java b/app/src/main/java/org/sil/hearthis/RequestFileHandler.java index a5bae8a..3d2ed59 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,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()) { diff --git a/app/src/main/java/org/sil/hearthis/SyncActivity.java b/app/src/main/java/org/sil/hearthis/SyncActivity.java index 2f8bbed..daefd93 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; // match the HearThis timeout? 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,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; } }); - } } } @@ -216,11 +262,8 @@ private String getOurIpAddress() { if (inetAddress.isSiteLocalAddress()) { return inetAddress.getHostAddress(); } - } - } - } catch (SocketException e) { // TODO Auto-generated catch block e.printStackTrace(); @@ -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(); + + // 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() { @@ -270,6 +339,8 @@ public void run() { @Override public void receivingFile(final String name) { + 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) @@ -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 { - - 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; - } - } } diff --git a/app/src/main/java/org/sil/hearthis/SyncServer.java b/app/src/main/java/org/sil/hearthis/SyncServer.java index f97e21e..d4d2457 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,7 @@ public void run() { DefaultHttpServerConnection serverConnection = new DefaultHttpServerConnection(); serverConnection.bind(socket, new BasicHttpParams()); - httpService.handleRequest(serverConnection, httpContext); - 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..5313ced --- /dev/null +++ b/app/src/main/java/org/sil/hearthis/Watchdog.java @@ -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; + } + + // 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(); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 471d647..25eb4ec 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 canceled + Sync timed out or had some other error. Please try again. Choose a project Choose a book Choose a chapter