diff --git a/build.gradle b/build.gradle index e80c10e2..3bbf2fcc 100644 --- a/build.gradle +++ b/build.gradle @@ -387,6 +387,8 @@ dependencies { // 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' 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' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 2cf89a2a..e9495bf1 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -47,7 +47,9 @@ android:screenOrientation="portrait"/> - + IGNORE_RESULT = new ValueCallback() { public void onReceiveValue(String result) { /* ignore */ } }; @@ -194,7 +191,8 @@ protected void onStop() { backButtonHandler); } - @Override protected void onActivityResult(int requestCd, int resultCode, Intent intent) { + @Override + protected void onActivityResult(int requestCd, int resultCode, Intent intent) { Optional requestCodeOpt = RequestCode.valueOf(requestCd); if (!requestCodeOpt.isPresent()) { @@ -212,14 +210,17 @@ protected void onStop() { this.filePickerHandler.processResult(resultCode, intent); return; case GRAB_MRDT_PHOTO_ACTIVITY: - processMrdtResult(requestCode, resultCode, intent); - return; - case DISCLOSURE_LOCATION_ACTIVITY: - processLocationPermissionResult(resultCode); + processMrdtResult(requestCode, intent); return; case CHT_EXTERNAL_APP_ACTIVITY: processChtExternalAppResult(resultCode, intent); return; + case ACCESS_STORAGE_PERMISSION: + processStoragePermissionResult(resultCode, intent); + return; + case ACCESS_LOCATION_PERMISSION: + processLocationPermissionResult(resultCode); + return; default: trace(this, "onActivityResult() :: no handling for requestCode=%s", requestCode.name()); } @@ -230,38 +231,6 @@ protected void onStop() { } } - @Override - public void onRequestPermissionsResult(int requestCd, String[] permissions, int[] grantResults) { - Optional requestCodeOpt = RequestCode.valueOf(requestCd); - - if (!requestCodeOpt.isPresent()) { - trace(this, "onRequestPermissionsResult() :: no handling for requestCode=%s", requestCd); - return; - } - - RequestCode requestCode = requestCodeOpt.get(); - super.onRequestPermissionsResult(requestCd, permissions, grantResults); - boolean granted = grantResults.length > 0 && grantResults[0] == PERMISSION_GRANTED; - - if (requestCode == RequestCode.ACCESS_LOCATION_PERMISSION) { - if (granted) { - locationRequestResolved(); - return; - } - processGeolocationDeniedStatus(); - return; - } - - if (requestCode == RequestCode.ACCESS_STORAGE_PERMISSION) { - if (granted) { - this.chtExternalAppHandler.resumeActivity(); - return; - } - trace(this, "ChtExternalAppHandler :: User rejected permission."); - return; - } - } - //> ACCESSORS MrdtSupport getMrdtSupport() { return this.mrdt; @@ -271,7 +240,7 @@ SmsSender getSmsSender() { return this.smsSender; } - ChtExternalAppHandler getChtExternalAppLauncherActivity() { + ChtExternalAppHandler getChtExternalAppHandler() { return this.chtExternalAppHandler; } @@ -321,47 +290,63 @@ public boolean getLocationPermissions() { } trace(this, "getLocationPermissions() :: location not granted before, requesting access..."); - Intent intent = new Intent(this, RequestPermissionActivity.class); - startActivityForResult(intent, RequestCode.DISCLOSURE_LOCATION_ACTIVITY.getCode()); + startActivityForResult( + new Intent(this, RequestLocationPermissionActivity.class), + RequestCode.ACCESS_LOCATION_PERMISSION.getCode() + ); return false; } - public void locationRequestResolved() { +//> PRIVATE HELPERS + private void locationRequestResolved() { evaluateJavascript("window.CHTCore.AndroidApi.v1.locationPermissionRequestResolved();"); } -//> PRIVATE HELPERS + private void processLocationPermissionResult(int resultCode) { + if (resultCode != RESULT_OK) { + try { + settings.setUserDeniedGeolocation(); + } catch (SettingsException e) { + error(e, "processLocationPermissionResult :: Error recording negative to access location."); + } + } + + locationRequestResolved(); + } + private void processChtExternalAppResult(int resultCode, Intent intentData) { String script = this.chtExternalAppHandler.processResult(resultCode, intentData); trace(this, "ChtExternalAppHandler :: Executing JavaScript: %s", script); evaluateJavascript(script); } - private void processMrdtResult(RequestCode requestCode, int resultCode, Intent intent) { + private void processMrdtResult(RequestCode requestCode, Intent intent) { String js = mrdt.process(requestCode, intent); trace(this, "Executing JavaScript: %s", js); evaluateJavascript(js); } - private void processLocationPermissionResult(int resultCode) { - if (resultCode == RESULT_OK) { - ActivityCompat.requestPermissions( - this, - LOCATION_PERMISSIONS, - RequestCode.ACCESS_LOCATION_PERMISSION.getCode() - ); - } else if (resultCode == RESULT_CANCELED) { - processGeolocationDeniedStatus(); + private void processStoragePermissionResult(int resultCode, Intent intent) { + String triggerClass = intent == null ? null : intent.getStringExtra(RequestStoragePermissionActivity.TRIGGER_CLASS); + + if (FilePickerHandler.class.getName().equals(triggerClass)) { + trace(this, "EmbeddedBrowserActivity :: Resuming FilePickerHandler process. Trigger:%s", triggerClass); + this.filePickerHandler.resumeProcess(resultCode); + return; } - } - private void processGeolocationDeniedStatus() { - try { - settings.setUserDeniedGeolocation(); - locationRequestResolved(); - } catch (SettingsException e) { - error(e, "LocationPermissionRequest :: Error recording negative to access location"); + if (ChtExternalAppHandler.class.getName().equals(triggerClass)) { + trace(this, "EmbeddedBrowserActivity :: Resuming ChtExternalAppHandler activity. Trigger:%s", triggerClass); + this.chtExternalAppHandler.resumeActivity(resultCode); + return; } + + trace( + this, + "EmbeddedBrowserActivity :: No handling for trigger: %s, requestCode: %s", + triggerClass, + RequestCode.ACCESS_STORAGE_PERMISSION.name() + ); } private void configureUserAgent() { @@ -454,9 +439,8 @@ public enum RequestCode { ACCESS_LOCATION_PERMISSION(100), ACCESS_STORAGE_PERMISSION(101), CHT_EXTERNAL_APP_ACTIVITY(102), - DISCLOSURE_LOCATION_ACTIVITY(103), - GRAB_MRDT_PHOTO_ACTIVITY(104), - FILE_PICKER_ACTIVITY(105); + GRAB_MRDT_PHOTO_ACTIVITY(103), + FILE_PICKER_ACTIVITY(104); private final int requestCode; diff --git a/src/main/java/org/medicmobile/webapp/mobile/FilePickerHandler.java b/src/main/java/org/medicmobile/webapp/mobile/FilePickerHandler.java index 5c8d38d8..7e5d7791 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/FilePickerHandler.java +++ b/src/main/java/org/medicmobile/webapp/mobile/FilePickerHandler.java @@ -1,6 +1,8 @@ package org.medicmobile.webapp.mobile; +import static android.Manifest.permission.READ_EXTERNAL_STORAGE; import static android.app.Activity.RESULT_OK; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static android.provider.MediaStore.ACTION_IMAGE_CAPTURE; import static android.provider.MediaStore.ACTION_VIDEO_CAPTURE; import static android.provider.MediaStore.Audio.Media.RECORD_SOUND_ACTION; @@ -16,6 +18,7 @@ import android.webkit.ValueCallback; import android.webkit.WebChromeClient.FileChooserParams; +import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import java.io.File; @@ -26,6 +29,7 @@ public class FilePickerHandler { private final Activity context; private ValueCallback filePickerCallback; + private String[] mimeTypes; private File tempFile; public FilePickerHandler(Activity context) { @@ -41,10 +45,13 @@ void setTempFile(File tempFile) { } void openPicker(FileChooserParams fileChooserParams, ValueCallback filePickerCallback) { - trace(this, "FilePickerHandler :: Start file capture activity"); setFilePickerCallback(filePickerCallback); setTempFile(null); - startFileCaptureActivity(fileChooserParams); + setMimeTypes(cleanMimeTypes(fileChooserParams)); + + if (checkPermissions()) { + startFileCaptureActivity(); + } } void processResult(int resultCode, Intent intent) { @@ -59,7 +66,35 @@ void processResult(int resultCode, Intent intent) { sendDataToWebapp(intent); } + void resumeProcess(int resultCode) { + if (resultCode == RESULT_OK) { + startFileCaptureActivity(); + return; + } + + // Giving control back to WebView without files. + sendDataToWebapp(null); + } + //> PRIVATE + private void setMimeTypes(String[] mimeTypes) { + this.mimeTypes = mimeTypes; + } + + private boolean checkPermissions() { + if (ContextCompat.checkSelfPermission(this.context, READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) { + return true; + } + + trace(this, "FilePickerHandler :: Requesting permissions."); + Intent intent = new Intent(this.context, RequestStoragePermissionActivity.class); + intent.putExtra( + RequestStoragePermissionActivity.TRIGGER_CLASS, + FilePickerHandler.class.getName() + ); + this.context.startActivityForResult(intent, RequestCode.ACCESS_STORAGE_PERMISSION.getCode()); + return false; + } /** * Executes the file picker callback. @@ -97,22 +132,21 @@ private Optional getSelectedFileUri(Intent intent) { return uri; } - private void startFileCaptureActivity(FileChooserParams fileChooserParams) { - String[] mimeTypes = cleanMimeTypes(fileChooserParams); - trace(this, "FilePickerHandler :: Accepted MIME types: %s", Arrays.toString(mimeTypes)); + private void startFileCaptureActivity() { + trace(this, "FilePickerHandler :: Accepted MIME types: %s", Arrays.toString(this.mimeTypes)); Intent pickerIntent = new Intent(Intent.ACTION_GET_CONTENT); pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); pickerIntent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); Intent captureIntent = null; - if (mimeTypes.length == 1) { - String mimeType = mimeTypes[0]; + if (this.mimeTypes.length == 1) { + String mimeType = this.mimeTypes[0]; pickerIntent.setType(mimeType); captureIntent = getCaptureIntent(mimeType); } else { pickerIntent.setType("*/*"); - pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, this.mimeTypes); } Intent chooserIntent = Intent.createChooser(pickerIntent, this.context.getString(R.string.promptChooseFile)); diff --git a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java index 253883ab..eb087d30 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -59,7 +59,7 @@ public MedicAndroidJavascript(EmbeddedBrowserActivity parent) { this.parent = parent; this.mrdt = parent.getMrdtSupport(); this.smsSender = parent.getSmsSender(); - this.chtExternalAppHandler = parent.getChtExternalAppLauncherActivity(); + this.chtExternalAppHandler = parent.getChtExternalAppHandler(); } public void setAlert(Alert soundAlert) { diff --git a/src/main/java/org/medicmobile/webapp/mobile/RequestLocationPermissionActivity.java b/src/main/java/org/medicmobile/webapp/mobile/RequestLocationPermissionActivity.java new file mode 100644 index 00000000..916d3f3f --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/RequestLocationPermissionActivity.java @@ -0,0 +1,108 @@ +package org.medicmobile.webapp.mobile; + +import static android.Manifest.permission.ACCESS_COARSE_LOCATION; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +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; + +/** + * Shows a confirmation view that displays a "prominent" disclosure about how + * the user geolocation data is used, asking to confirm whether to allow the app to + * access the location or not. + * + * If the user accepts, a request to the API to access the location is made by the main activity, + * but Android will show another confirmation dialog. If the user decline the first + * confirmation, the request to the API is omitted and the decision recorded to avoid + * requesting the same next time. + */ +public class RequestLocationPermissionActivity extends FragmentActivity { + static final String[] LOCATION_PERMISSIONS = { ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION }; + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), grantedMap -> { + boolean allGranted = grantedMap.size() > 0 && !grantedMap.containsValue(false); + + if (allGranted) { + trace(this, "RequestLocationPermissionActivity :: User allowed location permission."); + setResult(RESULT_OK); + finish(); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + boolean rationalFineLocation = !shouldShowRequestPermissionRationale(ACCESS_FINE_LOCATION); + boolean rationalCoarseLocation = !shouldShowRequestPermissionRationale(ACCESS_COARSE_LOCATION); + + if (rationalFineLocation || rationalCoarseLocation) { + trace( + this, + "RequestLocationPermissionActivity :: User rejected location 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, "RequestLocationPermissionActivity :: User rejected location permission."); + setResult(RESULT_CANCELED); + finish(); + }); + + private final ActivityResultLauncher appSettingsLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + boolean hasFineLocation = ContextCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED; + boolean hasCoarseLocation = ContextCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED; + + if (hasFineLocation && hasCoarseLocation) { + trace(this, "RequestLocationPermissionActivity :: User granted location permission from app's settings."); + setResult(RESULT_OK); + finish(); + return; + } + + trace(this, "RequestLocationPermissionActivity :: User didn't grant location 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_location_permission); + + String appName = getResources().getString(R.string.app_name); + String message = getResources().getString(R.string.locRequestMessage); + TextView field = findViewById(R.id.locMessageText); + field.setText(String.format(message, appName)); + } + + public void onClickOk(View view) { + trace(this, "RequestLocationPermissionActivity :: User agree with prominent disclosure message."); + requestPermissionLauncher.launch(LOCATION_PERMISSIONS); + } + + public void onClickNegative(View view) { + trace(this, "RequestLocationPermissionActivity :: User disagree with prominent disclosure message."); + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/RequestPermissionActivity.java b/src/main/java/org/medicmobile/webapp/mobile/RequestPermissionActivity.java deleted file mode 100644 index 957224ff..00000000 --- a/src/main/java/org/medicmobile/webapp/mobile/RequestPermissionActivity.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.medicmobile.webapp.mobile; - -import android.os.Bundle; -import android.view.View; -import android.view.Window; -import android.widget.TextView; - -import static org.medicmobile.webapp.mobile.MedicLog.trace; - -/** - * Shows a confirmation view that displays a "prominent" disclosure about how - * the user geolocation data is used, asking to confirm whether to allow the app to - * access the location or not. - * - * If the user accepts, a request to the API to access the location is made by the main activity, - * but Android will show another confirmation dialog. If the user decline the first - * confirmation, the request to the API is omitted and the decision recorded to avoid - * requesting the same next time. - */ -public class RequestPermissionActivity extends LockableActivity { - - @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - this.requestWindowFeature(Window.FEATURE_NO_TITLE); - setContentView(R.layout.request_permission); - String message = getResources().getString(R.string.locRequestMessage); - String appName = getResources().getString(R.string.app_name); - TextView field = (TextView) findViewById(R.id.locMessageText); - field.setText(String.format(message, appName)); - } - - public void onClickOk(View view) { - trace(this, "onClickOk() :: user accepted to share the location"); - setResult(RESULT_OK); - finish(); - } - - public void onClickNegative(View view) { - trace(this, ":: onClickNegative() :: user denied to share the location"); - setResult(RESULT_CANCELED); - finish(); - } -} diff --git a/src/main/java/org/medicmobile/webapp/mobile/RequestStoragePermissionActivity.java b/src/main/java/org/medicmobile/webapp/mobile/RequestStoragePermissionActivity.java new file mode 100644 index 00000000..77080521 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/RequestStoragePermissionActivity.java @@ -0,0 +1,104 @@ +package org.medicmobile.webapp.mobile; + +import static android.Manifest.permission.READ_EXTERNAL_STORAGE; +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 RequestStoragePermissionActivity extends FragmentActivity { + /** + * TRIGGER_CLASS {String} Extra in the request intent to specify the class that trigger this activity, + * it will be passed on to the result intent. It can be used to continue the + * action of the trigger class after the intent is resolved. + */ + static final String TRIGGER_CLASS = "TRIGGER_CLASS"; + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + Intent responseIntent = createResponseIntent(); + + if (isGranted) { + trace(this, "RequestStoragePermissionActivity :: User allowed storage permission."); + setResult(RESULT_OK, responseIntent); + finish(); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !shouldShowRequestPermissionRationale(READ_EXTERNAL_STORAGE)) { + trace( + this, + "RequestStoragePermissionActivity :: User rejected storage 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, "RequestStoragePermissionActivity :: User rejected storage permission."); + setResult(RESULT_CANCELED, responseIntent); + finish(); + }); + + private final ActivityResultLauncher appSettingsLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + Intent responseIntent = createResponseIntent(); + + if (ContextCompat.checkSelfPermission(this, READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) { + trace(this, "RequestStoragePermissionActivity :: User granted storage permission from app's settings."); + setResult(RESULT_OK, responseIntent); + finish(); + return; + } + + trace(this, "RequestStoragePermissionActivity :: User didn't grant storage permission from app's settings."); + setResult(RESULT_CANCELED, responseIntent); + finish(); + }); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + this.requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.request_storage_permission); + + String appName = getResources().getString(R.string.app_name); + String message = getResources().getString(R.string.storageRequestMessage); + TextView field = findViewById(R.id.storageMessageText); + field.setText(String.format(message, appName)); + } + + public void onClickAllow(View view) { + trace(this, "RequestStoragePermissionActivity :: User agree with prominent disclosure message."); + this.requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE); + } + + public void onClickDeny(View view) { + trace(this, "RequestStoragePermissionActivity :: User disagree with prominent disclosure message."); + setResult(RESULT_CANCELED, createResponseIntent()); + finish(); + } + + private Intent createResponseIntent() { + Intent requestIntent = getIntent(); + String triggerClass = requestIntent == null ? null : requestIntent.getStringExtra(this.TRIGGER_CLASS); + Intent responseIntent = new Intent(); + responseIntent.putExtra(this.TRIGGER_CLASS, triggerClass); + return responseIntent; + } +} diff --git a/src/main/res/drawable-hdpi/storage.png b/src/main/res/drawable-hdpi/storage.png new file mode 100644 index 00000000..40a7686e Binary files /dev/null and b/src/main/res/drawable-hdpi/storage.png differ diff --git a/src/main/res/drawable-mdpi/storage.png b/src/main/res/drawable-mdpi/storage.png new file mode 100644 index 00000000..38542ec8 Binary files /dev/null and b/src/main/res/drawable-mdpi/storage.png differ diff --git a/src/main/res/drawable-xhdpi/storage.png b/src/main/res/drawable-xhdpi/storage.png new file mode 100644 index 00000000..74a6cf68 Binary files /dev/null and b/src/main/res/drawable-xhdpi/storage.png differ diff --git a/src/main/res/drawable-xxhdpi/storage.png b/src/main/res/drawable-xxhdpi/storage.png new file mode 100644 index 00000000..1a4cc3ae Binary files /dev/null and b/src/main/res/drawable-xxhdpi/storage.png differ diff --git a/src/main/res/layout/request_permission.xml b/src/main/res/layout/request_location_permission.xml similarity index 100% rename from src/main/res/layout/request_permission.xml rename to src/main/res/layout/request_location_permission.xml diff --git a/src/main/res/layout/request_storage_permission.xml b/src/main/res/layout/request_storage_permission.xml new file mode 100644 index 00000000..e6b37aa4 --- /dev/null +++ b/src/main/res/layout/request_storage_permission.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + +