diff --git a/build.gradle b/build.gradle index 7d2d7cb9..a57cde6e 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.1' + classpath 'com.android.tools.build:gradle:7.1.3' classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.5' } } @@ -42,8 +42,8 @@ task pmd(type: Pmd) { exclude '**/**.mp3' exclude '**/**.bats' dependencies { - pmd 'net.sourceforge.pmd:pmd-java:6.36.0' - pmd 'net.sourceforge.pmd:pmd-xml:6.36.0' + pmd 'net.sourceforge.pmd:pmd-java:6.44.0' + pmd 'net.sourceforge.pmd:pmd-xml:6.44.0' } } @@ -95,7 +95,7 @@ def getVersionName = { } android { - compileSdkVersion 30 + compileSdkVersion 31 buildToolsVersion '30.0.3' packagingOptions { exclude 'META-INF/LICENSE' @@ -104,7 +104,7 @@ android { defaultConfig { //noinspection OldTargetApi - targetSdkVersion 30 + targetSdkVersion 31 minSdkVersion 21 // Android 5.0 versionCode getVersionCode() versionName getVersionName() @@ -394,21 +394,19 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - // Latest version of androidx.core requires Android 12+ - // noinspection GradleDependency - implementation 'androidx.core:core:1.6.0' - implementation 'androidx.activity:activity:1.3.1' - implementation 'androidx.fragment:fragment:1.3.6' + implementation 'androidx.core:core:1.7.0' + implementation 'androidx.activity:activity:1.4.0' + implementation 'androidx.fragment:fragment:1.4.1' compileOnly 'com.github.spotbugs:spotbugs-annotations:4.5.3' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-inline:4.0.0' testImplementation 'com.google.android:android-test:4.1.1.4' testImplementation 'org.robolectric:robolectric:4.7' - testImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha03' - testImplementation 'androidx.test.espresso:espresso-intents:3.5.0-alpha03' + testImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha05' + testImplementation 'androidx.test.espresso:espresso-intents:3.5.0-alpha05' testImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.0-alpha03' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.0-alpha05' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:core:1.4.0' diff --git a/config/pmd.xml b/config/pmd.xml index f17c8e1c..e42c1d7c 100644 --- a/config/pmd.xml +++ b/config/pmd.xml @@ -11,7 +11,7 @@ - + diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index a1bf8d68..b3de091e 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -22,11 +22,16 @@ + + + + tools:ignore="LockedOrientationActivity,UnusedAttribute"> @@ -48,6 +53,8 @@ android:screenOrientation="portrait"/> + diff --git a/src/main/java/medic/android/ActivityBackgroundTask.java b/src/main/java/medic/android/ActivityBackgroundTask.java deleted file mode 100644 index 9926246b..00000000 --- a/src/main/java/medic/android/ActivityBackgroundTask.java +++ /dev/null @@ -1,47 +0,0 @@ -package medic.android; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.os.AsyncTask; - -import java.lang.ref.WeakReference; - -import static android.os.Build.VERSION.SDK_INT; -import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; - -// TODO this was copy/pasted from medic-gateway, and we should consider sharing code more gracefully between projects -public abstract class ActivityBackgroundTask extends AsyncTask { - private final WeakReference parent; - - public ActivityBackgroundTask(Parent parent) { - super(); - this.parent = new WeakReference<>(parent); - } - - /** - * @param caller the name of the calling class and method for use in logging and Throwables. - * @return the parent context of this task - * @throws IllegalStateException if no parent context was found - */ - protected Parent getRequiredCtx(String caller) { - Parent ctx = getCtx(); - - if(ctx == null) throw new IllegalStateException(String.format("%s :: couldn't get parent activity.", caller)); - - return ctx; - } - - /** - * @return the parent context of this task, or null if the task is finishing, is destroyed, or has been dereferenced. - */ - @SuppressLint("ObsoleteSdkInt") - protected Parent getCtx() { - Parent parent = this.parent.get(); - - if(parent == null) return null; - if(parent.isFinishing()) return null; - if(SDK_INT >= JELLY_BEAN_MR1 && parent.isDestroyed()) return null; - - else return parent; - } -} diff --git a/src/main/java/org/medicmobile/webapp/mobile/Alert.java b/src/main/java/org/medicmobile/webapp/mobile/Alert.java index 74d2ab0d..ac302e24 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/Alert.java +++ b/src/main/java/org/medicmobile/webapp/mobile/Alert.java @@ -2,19 +2,20 @@ import android.content.Context; import android.media.MediaPlayer; -import android.os.Vibrator; + +import org.medicmobile.webapp.mobile.util.Vibrator; public class Alert { - private final MediaPlayer m; - private final Vibrator v; + private final MediaPlayer mediaPlayer; + private final Vibrator vibrator; - public Alert(Context ctx) { - m = MediaPlayer.create(ctx, R.raw.sound_alert); - v = (Vibrator) ctx.getSystemService(Context.VIBRATOR_SERVICE); + public Alert(Context context) { + mediaPlayer = MediaPlayer.create(context, R.raw.sound_alert); + vibrator = Vibrator.createInstance(context); } public void trigger() { - if(v != null) v.vibrate(1500L); - m.start(); + vibrator.vibrate(1500L); + mediaPlayer.start(); } } diff --git a/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java b/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java index 3190547e..aca4affd 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java +++ b/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java @@ -1,9 +1,5 @@ package org.medicmobile.webapp.mobile; -import java.io.IOException; -import java.net.MalformedURLException; -import org.json.JSONException; -import org.json.JSONObject; import static org.medicmobile.webapp.mobile.MedicLog.trace; import static org.medicmobile.webapp.mobile.R.string.errAppUrl_apiNotReady; import static org.medicmobile.webapp.mobile.R.string.errAppUrl_appNotFound; @@ -11,23 +7,37 @@ import static org.medicmobile.webapp.mobile.R.string.errInvalidUrl; import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; -public class AppUrlVerifier { +import org.json.JSONException; +import org.json.JSONObject; +import org.medicmobile.webapp.mobile.AppUrlVerifier.AppUrlVerification; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.concurrent.Callable; + +public class AppUrlVerifier implements Callable { private final SimpleJsonClient2 jsonClient; + private final String appUrl; + + AppUrlVerifier(SimpleJsonClient2 jsonClient, String appUrl) { + if (Utils.isDebug() && (appUrl == null || appUrl.trim().isEmpty())) { + throw new RuntimeException("AppUrlVerifier :: Cannot verify APP URL because it is not defined."); + } - AppUrlVerifier(SimpleJsonClient2 jsonClient) { this.jsonClient = jsonClient; + this.appUrl = appUrl; } - public AppUrlVerifier() { - this(new SimpleJsonClient2()); + public AppUrlVerifier(String appUrl) { + this(new SimpleJsonClient2(), appUrl); } /** * Verify the string passed is a valid CHT-Core URL. */ - public AppUrlVerification verify(String appUrl) { - appUrl = clean(appUrl); + public AppUrlVerification call() { + String appUrl = clean(this.appUrl); try { JSONObject json = jsonClient.get(appUrl + "/setup/poll"); @@ -57,33 +67,33 @@ public AppUrlVerification verify(String appUrl) { * Clean-up the URL passed, removing leading and trailing spaces, and trailing "/" char * that the user may input by mistake. */ - protected String clean(String appUrl) { + public static String clean(String appUrl) { appUrl = appUrl.trim(); if (appUrl.endsWith("/")) { return appUrl.substring(0, appUrl.length()-1); } return appUrl; } -} -@SuppressWarnings("PMD.ShortMethodName") -class AppUrlVerification { - public final String appUrl; - public final boolean isOk; - public final int failure; + @SuppressWarnings("PMD.ShortMethodName") + public static class AppUrlVerification { + public final String appUrl; + public final boolean isOk; + public final int failure; - private AppUrlVerification(String appUrl, boolean isOk, int failure) { - this.appUrl = appUrl; - this.isOk = isOk; - this.failure = failure; - } + private AppUrlVerification(String appUrl, boolean isOk, int failure) { + this.appUrl = appUrl; + this.isOk = isOk; + this.failure = failure; + } -//> FACTORIES - public static AppUrlVerification ok(String appUrl) { - return new AppUrlVerification(appUrl, true, 0); - } + //> FACTORIES + public static AppUrlVerification ok(String appUrl) { + return new AppUrlVerification(appUrl, true, 0); + } - public static AppUrlVerification failure(String appUrl, int failure) { - return new AppUrlVerification(appUrl, false, failure); + public static AppUrlVerification failure(String appUrl, int failure) { + return new AppUrlVerification(appUrl, false, failure); + } } } diff --git a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java index 03a4bbe2..171a85f1 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java @@ -73,7 +73,7 @@ public void onReceiveValue(String result) { this.chtExternalAppHandler = new ChtExternalAppHandler(this); try { - this.smsSender = new SmsSender(this); + this.smsSender = SmsSender.createInstance(this); } catch(Exception ex) { error(ex, "Failed to create SmsSender."); } @@ -205,6 +205,9 @@ protected void onActivityResult(int requestCd, int resultCode, Intent intent) { case ACCESS_LOCATION_PERMISSION: processLocationPermissionResult(resultCode); return; + case ACCESS_SEND_SMS_PERMISSION: + this.smsSender.resumeProcess(resultCode); + return; default: trace(this, "onActivityResult() :: no handling for requestCode=%s", requestCode.name()); } @@ -417,9 +420,10 @@ private void registerRetryConnectionBroadcastReceiver() { public enum RequestCode { ACCESS_LOCATION_PERMISSION(100), ACCESS_STORAGE_PERMISSION(101), - CHT_EXTERNAL_APP_ACTIVITY(102), - GRAB_MRDT_PHOTO_ACTIVITY(103), - FILE_PICKER_ACTIVITY(104); + ACCESS_SEND_SMS_PERMISSION(102), + CHT_EXTERNAL_APP_ACTIVITY(103), + GRAB_MRDT_PHOTO_ACTIVITY(104), + FILE_PICKER_ACTIVITY(105); private final int requestCode; diff --git a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java index eb087d30..aac1e627 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -179,7 +179,7 @@ public boolean sms_available() { public void sms_send(String id, String destination, String content) throws Exception { try { // TODO we may need to do this on a background thread to avoid the browser UI from blocking while the SMS is being sent. Check. - smsSender.send(id, destination, content); + smsSender.send(new SmsSender.Sms(id, destination, content)); } catch(Exception ex) { logException(ex); throw ex; diff --git a/src/main/java/org/medicmobile/webapp/mobile/RequestSendSmsPermissionActivity.java b/src/main/java/org/medicmobile/webapp/mobile/RequestSendSmsPermissionActivity.java new file mode 100644 index 00000000..643a4b55 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/RequestSendSmsPermissionActivity.java @@ -0,0 +1,86 @@ +package org.medicmobile.webapp.mobile; + +import static android.Manifest.permission.SEND_SMS; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static org.medicmobile.webapp.mobile.MedicLog.trace; + +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.view.View; +import android.view.Window; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; + +public class RequestSendSmsPermissionActivity extends FragmentActivity { + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) { + trace(this, "RequestSendSmsPermissionActivity :: User allowed Send SMS permission."); + setResult(RESULT_OK); + finish(); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !shouldShowRequestPermissionRationale(SEND_SMS)) { + trace( + this, + "RequestSendSmsPermissionActivity :: User rejected Send SMS permission twice or has selected \"never ask again\"." + + " Sending user to the app's setting to manually grant the permission." + ); + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", getPackageName(), null)); + this.appSettingsLauncher.launch(intent); + return; + } + + trace(this, "RequestSendSmsPermissionActivity :: User rejected Send SMS permission."); + setResult(RESULT_CANCELED); + finish(); + }); + + private final ActivityResultLauncher appSettingsLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if (ContextCompat.checkSelfPermission(this, SEND_SMS) == PERMISSION_GRANTED) { + trace(this, "RequestSendSmsPermissionActivity :: User granted Send SMS permission from app's settings."); + setResult(RESULT_OK); + finish(); + return; + } + + trace(this, "RequestSendSmsPermissionActivity :: User didn't grant Send SMS permission from app's settings."); + setResult(RESULT_CANCELED); + finish(); + }); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + this.requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.request_send_sms_permission); + + String appName = getResources().getString(R.string.app_name); + String message = getResources().getString(R.string.sendSmsRequestMessage); + TextView field = findViewById(R.id.sendSmsMessageText); + field.setText(String.format(message, appName)); + } + + public void onClickAllow(View view) { + trace(this, "RequestSendSmsPermissionActivity :: User agree with prominent disclosure message."); + this.requestPermissionLauncher.launch(SEND_SMS); + } + + public void onClickDeny(View view) { + trace(this, "RequestSendSmsPermissionActivity :: User disagree with prominent disclosure message."); + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java b/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java index 54042ee0..cf555856 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java @@ -1,6 +1,5 @@ package org.medicmobile.webapp.mobile; -import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; import static org.medicmobile.webapp.mobile.MedicLog.trace; import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; @@ -20,47 +19,20 @@ import android.widget.SimpleAdapter; import android.widget.TextView; +import org.medicmobile.webapp.mobile.util.AsyncExecutor; + import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Map; -import medic.android.ActivityBackgroundTask; - public class SettingsDialogActivity extends Activity { - private static final int STATE_LIST = 1; private static final int STATE_FORM = 2; private SettingsStore settings; private ServerRepo serverRepo; private int state; - private static class AppUrlVerificationTask extends ActivityBackgroundTask { - - private final AppUrlVerifier verifier = new AppUrlVerifier(); - - AppUrlVerificationTask(SettingsDialogActivity a) { - super(a); - } - - protected AppUrlVerification doInBackground(String... appUrl) { - if(DEBUG && appUrl.length != 1) throw new IllegalArgumentException(); - return verifier.verify(appUrl[0]); - } - protected void onPostExecute(AppUrlVerification result) { - SettingsDialogActivity ctx = getRequiredCtx("AppUrlVerificationTask.onPostExecute()"); - - if(result.isOk) { - ctx.saveSettings(new WebappSettings(result.appUrl)); - ctx.serverRepo.save(result.appUrl); - } else { - ctx.showError(R.id.txtAppUrl, result.failure); - ctx.submitButton().setEnabled(true); - ctx.cancelButton().setEnabled(true); - } - } - } - @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); trace(this, "onCreate()"); @@ -111,7 +83,23 @@ public void verifyAndSave(View view) { String appUrl = text(R.id.txtAppUrl); - new AppUrlVerificationTask(this).execute(appUrl); + AsyncExecutor asyncExecutor = new AsyncExecutor(); + asyncExecutor.executeAsync(new AppUrlVerifier(appUrl), (result) -> { + trace( + this, + "SettingsDialogActivity :: Executing verification callback, result isOkay=%s, appUrl=%s", + result.isOk, result.appUrl + ); + + if (result.isOk) { + saveSettings(new WebappSettings(result.appUrl)); + serverRepo.save(result.appUrl); + return; + } + showError(R.id.txtAppUrl, result.failure); + submitButton().setEnabled(true); + cancelButton().setEnabled(true); + }); } public void cancelSettingsEdit(View view) { diff --git a/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java b/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java index 1bc138c8..c1454dfd 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java @@ -27,7 +27,11 @@ public String getUrlToLoad(Uri url) { } public boolean isRootUrl(String url) { - return getAppUrl().equals(url); + if (url == null) { + return false; + } + + return getAppUrl().equals(AppUrlVerifier.clean(url)); } public abstract boolean hasWebappSettings(); diff --git a/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java b/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java index 56c1662c..00917df9 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java @@ -1,6 +1,23 @@ package org.medicmobile.webapp.mobile; -import android.annotation.SuppressLint; +import static android.Manifest.permission.SEND_SMS; +import static android.app.Activity.RESULT_OK; +import static android.app.PendingIntent.FLAG_IMMUTABLE; +import static android.app.PendingIntent.FLAG_ONE_SHOT; +import static android.app.PendingIntent.getBroadcast; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static android.telephony.SmsManager.RESULT_ERROR_GENERIC_FAILURE; +import static android.telephony.SmsManager.RESULT_ERROR_NO_SERVICE; +import static android.telephony.SmsManager.RESULT_ERROR_NULL_PDU; +import static android.telephony.SmsManager.RESULT_ERROR_RADIO_OFF; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.RequestCode; +import static org.medicmobile.webapp.mobile.JavascriptUtils.safeFormat; +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.trace; +import static org.medicmobile.webapp.mobile.MedicLog.warn; +import static java.lang.Integer.toHexString; + +import android.annotation.TargetApi; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; @@ -10,17 +27,11 @@ import android.telephony.SmsManager; import android.telephony.SmsMessage; -import java.util.ArrayList; +import androidx.core.content.ContextCompat; -import static java.lang.Integer.toHexString; -import static org.medicmobile.webapp.mobile.JavascriptUtils.safeFormat; -import static org.medicmobile.webapp.mobile.MedicLog.log; -import static org.medicmobile.webapp.mobile.MedicLog.warn; -import static android.app.Activity.RESULT_OK; -import static android.telephony.SmsManager.RESULT_ERROR_GENERIC_FAILURE; -import static android.telephony.SmsManager.RESULT_ERROR_NO_SERVICE; -import static android.telephony.SmsManager.RESULT_ERROR_NULL_PDU; -import static android.telephony.SmsManager.RESULT_ERROR_RADIO_OFF; +import java.util.ArrayList; +import java.util.stream.Collectors; +import java.util.stream.IntStream; class SmsSender { private static final int UNUSED_REQUEST_CODE = 0; @@ -30,11 +41,10 @@ class SmsSender { private static final String DELIVERY_REPORT = "medic.android.sms.DELIVERY_REPORT"; private final EmbeddedBrowserActivity parent; - private final SmsManager smsManager; + private Sms sms; - SmsSender(EmbeddedBrowserActivity parent) { + protected SmsSender(EmbeddedBrowserActivity parent) { this.parent = parent; - this.smsManager = SmsManager.getDefault(); parent.registerReceiver(new BroadcastReceiver() { @Override public void onReceive(Context ctx, Intent intent) { @@ -59,16 +69,80 @@ class SmsSender { }, createIntentFilter()); } - void send(String id, String destination, String content) { - ArrayList parts = smsManager.divideMessage(content); - smsManager.sendMultipartTextMessage(destination, - DEFAULT_SMSC, - parts, - intentsFor(SENDING_REPORT, id, destination, content, parts), - intentsFor(DELIVERY_REPORT, id, destination, content, parts)); + public static SmsSender createInstance(EmbeddedBrowserActivity parent) { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return new LSmsSender(parent); + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return new RSmsSender(parent); + } + return new SmsSender(parent); + } + + void send(Sms sms) { + if (!checkPermissions()) { + this.sms = sms; + return; + } + + sendSmsMultipart(sms); + } + + void resumeProcess(int resultCode) { + if (resultCode == RESULT_OK && this.sms != null) { + sendSmsMultipart(this.sms); + this.sms = null; + return; + } + + trace(this.parent, "SmsSender :: Cannot send sms without Send SMS permission. Sms ID=%s", this.sms.getId()); + } + + @TargetApi(31) + protected SmsManager getManager() { + return parent.getSystemService(android.telephony.SmsManager.class); + } + + @TargetApi(23) + protected int getBroadcastFlags() { + return FLAG_ONE_SHOT | FLAG_IMMUTABLE; + } + + /** + * @see createFromPdu(byte[]) + */ + @TargetApi(23) + protected SmsMessage createFromPdu(Intent intent) { + byte[] pdu = intent.getByteArrayExtra("pdu"); + String format = intent.getStringExtra("format"); + return SmsMessage.createFromPdu(pdu, format); } //> PRIVATE HELPERS + + private void sendSmsMultipart(Sms sms) { + SmsManager smsManager = getManager(); + ArrayList parts = smsManager.divideMessage(sms.getContent()); + + smsManager.sendMultipartTextMessage( + sms.getDestination(), + DEFAULT_SMSC, + parts, + createIntentsFromSmsParts(SENDING_REPORT, sms, parts), + createIntentsFromSmsParts(DELIVERY_REPORT, sms, parts) + ); + } + + private boolean checkPermissions() { + if (ContextCompat.checkSelfPermission(this.parent, SEND_SMS) == PERMISSION_GRANTED) { + return true; + } + + trace(this, "SmsSender :: Requesting permissions."); + Intent intent = new Intent(this.parent, RequestSendSmsPermissionActivity.class); + this.parent.startActivityForResult(intent, RequestCode.ACCESS_SEND_SMS_PERMISSION.getCode()); + return false; + } + private void reportStatus(Intent intent, String status) { reportStatus(intent, status, null); } @@ -95,23 +169,20 @@ private String describe(Intent intent) { return String.format("[id:%s to %s (part %s) content:%s]", id, destination, part, content); } - private ArrayList intentsFor(String intentType, String id, String destination, String content, ArrayList parts) { + private ArrayList createIntentsFromSmsParts(String intentType, Sms sms, ArrayList parts) { int totalParts = parts.size(); - ArrayList intents = new ArrayList<>(totalParts); - for(int partIndex=0; partIndex intentFor(intentType, sms, index, totalParts)) + .collect(Collectors.toCollection(ArrayList::new)); } - @SuppressLint("UnspecifiedImmutableFlag") - private PendingIntent intentFor(String intentType, String id, String destination, String content, int partIndex, int totalParts) { + private PendingIntent intentFor(String intentType, Sms sms, int partIndex, int totalParts) { Intent intent = new Intent(intentType); - intent.putExtra("id", id); - intent.putExtra("destination", destination); - intent.putExtra("content", content); + intent.putExtra("id", sms.getId()); + intent.putExtra("destination", sms.getDestination()); + intent.putExtra("content", sms.getContent()); intent.putExtra("partIndex", partIndex); intent.putExtra("totalParts", totalParts); @@ -120,24 +191,10 @@ private PendingIntent intentFor(String intentType, String id, String destination // collisions. There is a small chance of collisions if two // SMS are in-flight at the same time and are given the same id. - return PendingIntent.getBroadcast(parent, UNUSED_REQUEST_CODE, intent, PendingIntent.FLAG_ONE_SHOT); + return getBroadcast(parent, UNUSED_REQUEST_CODE, intent, getBroadcastFlags()); } //> STATIC HELPERS - /** - * @see https://developer.android.com/reference/android/telephony/SmsMessage.html#createFromPdu%28byte[],%20java.lang.String%29 - */ - @SuppressLint("ObsoleteSdkInt") - private static SmsMessage createFromPdu(Intent intent) { - byte[] pdu = intent.getByteArrayExtra("pdu"); - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - String format = intent.getStringExtra("format"); - return SmsMessage.createFromPdu(pdu, format); - } else { - return SmsMessage.createFromPdu(pdu); - } - } - private static IntentFilter createIntentFilter() { IntentFilter filter = new IntentFilter(); @@ -151,7 +208,7 @@ private static IntentFilter createIntentFilter() { class DeliveryReportHandler { /** * Mask for differentiating GSM and CDMA message statuses. - * @see https://developer.android.com/reference/android/telephony/SmsMessage.html#getStatus%28%29 + * @see ETSI TS */ @SuppressWarnings("PMD.EmptyIfStmt") private void handleGsmDelivery(Intent intent, int status) { // Detail of the failure. Must be set for FAILED messages. - String fDetail = null; + String fDetail; if(status < 0x20) { //> Short message transaction completed @@ -281,4 +338,53 @@ private String getGenericFailureReason(Intent intent) { } } } + + static class RSmsSender extends SmsSender { + + RSmsSender(EmbeddedBrowserActivity parent) { + super(parent); + } + + protected SmsManager getManager(){ + return SmsManager.getDefault(); + } + } + + static class LSmsSender extends RSmsSender { + LSmsSender(EmbeddedBrowserActivity parent) { + super(parent); + } + + protected int getBroadcastFlags() { + return FLAG_ONE_SHOT; + } + + protected SmsMessage createFromPdu(Intent intent) { + return SmsMessage.createFromPdu(intent.getByteArrayExtra("pdu")); + } + } + + static class Sms { + private final String id; + private final String destination; + private final String content; + + public Sms(String id, String destination, String content) { + this.id = id; + this.destination = destination; + this.content = content; + } + + public String getId() { + return id; + } + + public String getDestination() { + return destination; + } + + public String getContent() { + return content; + } + } } diff --git a/src/main/java/org/medicmobile/webapp/mobile/Utils.java b/src/main/java/org/medicmobile/webapp/mobile/Utils.java index 0bcf2a6a..e4188663 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/Utils.java +++ b/src/main/java/org/medicmobile/webapp/mobile/Utils.java @@ -1,7 +1,10 @@ package org.medicmobile.webapp.mobile; +import static org.medicmobile.webapp.mobile.BuildConfig.APPLICATION_ID; +import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; +import static org.medicmobile.webapp.mobile.BuildConfig.VERSION_NAME; + import android.app.Activity; -import android.app.ProgressDialog; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -9,10 +12,6 @@ import org.json.JSONException; import org.json.JSONObject; -import static org.medicmobile.webapp.mobile.BuildConfig.APPLICATION_ID; -import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; -import static org.medicmobile.webapp.mobile.BuildConfig.VERSION_NAME; - import java.io.File; import java.util.Optional; @@ -78,20 +77,6 @@ static void startAppActivityChain(Activity a) { a.finish(); } - public static ProgressDialog showSpinner(Context ctx, int messageId) { - return showSpinner(ctx, ctx.getString(messageId)); - } - - public static ProgressDialog showSpinner(Context ctx, String message) { - ProgressDialog p = new ProgressDialog(ctx); - p.setProgressStyle(ProgressDialog.STYLE_SPINNER); - if(message != null) p.setMessage(message); - p.setIndeterminate(true); - p.setCanceledOnTouchOutside(false); - p.show(); - return p; - } - static String createUseragentFrom(String current) { if(current.contains(APPLICATION_ID)) return current; @@ -128,4 +113,8 @@ static Optional getUriFromFilePath(String path) { return Optional.of(Uri.fromFile(file)); } + + static boolean isDebug() { + return DEBUG; + } } diff --git a/src/main/java/org/medicmobile/webapp/mobile/util/AsyncExecutor.java b/src/main/java/org/medicmobile/webapp/mobile/util/AsyncExecutor.java new file mode 100644 index 00000000..31591315 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/util/AsyncExecutor.java @@ -0,0 +1,31 @@ +package org.medicmobile.webapp.mobile.util; + +import static org.medicmobile.webapp.mobile.MedicLog.error; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +public class AsyncExecutor { + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler handler = new Handler(Looper.getMainLooper()); + + public

Future

executeAsync(Callable

callable, Consumer

callback) { + error(null, "AsyncExecutor :: Error executing the task."); + return executor.submit(() -> { + try { + final P result = callable.call(); + handler.post(() -> callback.accept(result)); + return result; + } catch (Exception exception) { + error(exception, "AsyncExecutor :: Error executing the task."); + throw new RuntimeException(exception); + } + }); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/util/Vibrator.java b/src/main/java/org/medicmobile/webapp/mobile/util/Vibrator.java new file mode 100644 index 00000000..5c11713b --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/util/Vibrator.java @@ -0,0 +1,57 @@ +package org.medicmobile.webapp.mobile.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.VibratorManager; + +public class Vibrator { + protected final Context context; + + protected Vibrator(Context context) { + this.context = context; + } + + public static Vibrator createInstance(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return new NVibrator(context); + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return new RVibrator(context); + } + return new Vibrator(context); + } + + @TargetApi(26) + public void vibrate(long milliseconds) { + getVibrator().vibrate(VibrationEffect.createOneShot(milliseconds, VibrationEffect.DEFAULT_AMPLITUDE)); + } + + @TargetApi(31) + protected android.os.Vibrator getVibrator() { + VibratorManager vibratorManager = (VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); + return vibratorManager.getDefaultVibrator(); + } + + static class RVibrator extends Vibrator { + protected RVibrator(Context context) { + super(context); + } + + @Override + protected android.os.Vibrator getVibrator() { + return (android.os.Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + } + } + + static class NVibrator extends RVibrator { + protected NVibrator(Context context) { + super(context); + } + + @Override + public void vibrate(long milliseconds) { + getVibrator().vibrate(milliseconds); + } + } +} diff --git a/src/main/res/drawable-hdpi/send_sms.png b/src/main/res/drawable-hdpi/send_sms.png new file mode 100644 index 00000000..6439f7d1 Binary files /dev/null and b/src/main/res/drawable-hdpi/send_sms.png differ diff --git a/src/main/res/drawable-mdpi/send_sms.png b/src/main/res/drawable-mdpi/send_sms.png new file mode 100644 index 00000000..134b6630 Binary files /dev/null and b/src/main/res/drawable-mdpi/send_sms.png differ diff --git a/src/main/res/drawable-xhdpi/send_sms.png b/src/main/res/drawable-xhdpi/send_sms.png new file mode 100644 index 00000000..d524e74d Binary files /dev/null and b/src/main/res/drawable-xhdpi/send_sms.png differ diff --git a/src/main/res/drawable-xxhdpi/send_sms.png b/src/main/res/drawable-xxhdpi/send_sms.png new file mode 100644 index 00000000..aa520b17 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/send_sms.png differ diff --git a/src/main/res/layout/request_send_sms_permission.xml b/src/main/res/layout/request_send_sms_permission.xml new file mode 100644 index 00000000..f8476b78 --- /dev/null +++ b/src/main/res/layout/request_send_sms_permission.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + +