diff --git a/build.gradle b/build.gradle index c7c0881a..7ec96733 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.1.3' classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.5' + classpath 'com.google.dagger:hilt-android-gradle-plugin:2.44.2' } } @@ -15,6 +16,7 @@ apply plugin: 'com.android.application' apply plugin: 'checkstyle' apply plugin: 'pmd' apply plugin: 'com.github.spotbugs' +apply plugin: 'com.google.dagger.hilt.android' apply from: 'coverage.gradle' // enable verbose lint warnings @@ -412,6 +414,8 @@ dependencies { implementation 'androidx.core:core:1.7.0' implementation 'androidx.activity:activity:1.4.0' implementation 'androidx.fragment:fragment:1.4.1' + implementation 'com.google.dagger:hilt-android:2.44.2' + annotationProcessor "com.google.dagger:hilt-compiler:2.44.2" 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/androidTestMedicmobilegammaDebug/java/org/medicmobile/webapp/mobile/LoginTests.java b/src/androidTestMedicmobilegammaDebug/java/org/medicmobile/webapp/mobile/LoginTests.java index 92ed8e95..f7a18f43 100644 --- a/src/androidTestMedicmobilegammaDebug/java/org/medicmobile/webapp/mobile/LoginTests.java +++ b/src/androidTestMedicmobilegammaDebug/java/org/medicmobile/webapp/mobile/LoginTests.java @@ -40,6 +40,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.MethodSorters; +import org.medicmobile.webapp.mobile.components.settings_dialog.SettingsDialogActivity; import java.util.Locale; @LargeTest diff --git a/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java index d7011b80..43de12f1 100644 --- a/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java +++ b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java @@ -39,6 +39,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.MethodSorters; +import org.medicmobile.webapp.mobile.components.settings_dialog.SettingsDialogActivity; import java.util.Locale; diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index e9d68e7d..6b611219 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -26,7 +26,8 @@ --> - - diff --git a/src/main/java/org/medicmobile/webapp/mobile/ChtAndroidApplication.java b/src/main/java/org/medicmobile/webapp/mobile/ChtAndroidApplication.java new file mode 100644 index 00000000..9b80c9d8 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/ChtAndroidApplication.java @@ -0,0 +1,9 @@ +package org.medicmobile.webapp.mobile; + +import android.app.Application; + +import dagger.hilt.android.HiltAndroidApp; + +@HiltAndroidApp +public class ChtAndroidApplication extends Application { +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java index dafa0975..91d58887 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java @@ -34,6 +34,8 @@ import androidx.core.content.ContextCompat; +import org.medicmobile.webapp.mobile.SettingsStore.SettingsException; + import java.util.Arrays; import java.util.Optional; diff --git a/src/main/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragment.java b/src/main/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragment.java index bc688d76..8419bec5 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragment.java +++ b/src/main/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragment.java @@ -11,6 +11,8 @@ import androidx.annotation.Nullable; +import org.medicmobile.webapp.mobile.components.settings_dialog.SettingsDialogActivity; + import java.time.Clock; @SuppressLint("ValidFragment") diff --git a/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java b/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java deleted file mode 100644 index 796b06df..00000000 --- a/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java +++ /dev/null @@ -1,364 +0,0 @@ -package org.medicmobile.webapp.mobile; - -import static org.medicmobile.webapp.mobile.MedicLog.trace; -import static org.medicmobile.webapp.mobile.MedicLog.error; -import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.content.res.XmlResourceParser; -import android.os.Bundle; -import android.view.View; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ListView; -import android.widget.TextView; - -import androidx.fragment.app.FragmentActivity; - -import org.medicmobile.webapp.mobile.adapters.FilterableListAdapter; -import org.medicmobile.webapp.mobile.dialogs.ConfirmServerSelectionDialog; -import org.medicmobile.webapp.mobile.listeners.TextChangedListener; -import org.medicmobile.webapp.mobile.util.AsyncExecutor; -import org.xmlpull.v1.XmlPullParser; -import org.xmlpull.v1.XmlPullParserException; - -import java.io.IOException; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class SettingsDialogActivity extends FragmentActivity { - private static final int STATE_LIST = 1; - private static final int STATE_FORM = 2; - private SettingsStore settings; - private ServerRepo serverRepo; - private int state; - - @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - trace(this, "onCreate()"); - - this.settings = SettingsStore.in(this); - this.serverRepo = new ServerRepo(this, this.settings); - - displayServerSelectList(); - } - -//> STATE CHANGE HANDLERS - private void displayServerSelectList() { - state = STATE_LIST; - - setContentView(R.layout.server_select_list); - - ListView list = findViewById(R.id.lstServers); - - List servers = serverRepo.getServers(); - ServerMetadataAdapter adapter = ServerMetadataAdapter.createInstance(this, servers); - list.setAdapter(adapter); - list.setOnItemClickListener(new ServerClickListener(adapter)); - - TextView seachBox = findViewById(R.id.instanceSearchBox); - seachBox.addTextChangedListener(new TextChangedListener() { - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - adapter.getFilter().filter(s.toString()); - } - }); - } - - private void displayCustomServerForm() { - state = STATE_FORM; - - setContentView(R.layout.custom_server_form); - - if(!this.settings.hasWebappSettings()) { - cancelButton().setVisibility(View.GONE); - } - - text(R.id.txtAppUrl, settings.getAppUrl()); - } - -//> EVENT HANDLERS - public void verifyAndSave(View view) { - trace(this, "verifyAndSave()"); - - submitButton().setEnabled(false); - cancelButton().setEnabled(false); - - String appUrl = text(R.id.txtAppUrl); - - 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) { - trace(this, "cancelSettingsEdit()"); - backToWebview(); - } - - @Override public void onBackPressed() { - switch(state) { - case STATE_LIST: - if(this.settings.hasWebappSettings()) { - backToWebview(); - return; - } - break; - case STATE_FORM: - displayServerSelectList(); - return; - } - super.onBackPressed(); - } - -//> PRIVATE HELPERS - private void backToWebview() { - startActivity(new Intent(this, EmbeddedBrowserActivity.class)); - finish(); - } - - private void saveSettings(WebappSettings s) { - try { - settings.updateWith(s); - this.backToWebview(); - } catch(IllegalSettingsException ex) { - trace(ex, "Tried to save illegal setting."); - for(IllegalSetting error : ex.errors) { - showError(error); - } - } catch(SettingsException ex) { - trace(ex, "Problem saving settings."); - submitButton().setError(ex.getMessage()); - } - } - - private Button cancelButton() { - return (Button) findViewById(R.id.btnCancelSettings); - } - - private Button submitButton() { - return (Button) findViewById(R.id.btnSaveSettings); - } - - private String text(int componentId) { - EditText field = (EditText) findViewById(componentId); - return field.getText().toString(); - } - - private void text(int componentId, String value) { - EditText field = (EditText) findViewById(componentId); - field.setText(value); - } - - private void showError(IllegalSetting error) { - showError(error.componentId, error.errorStringId); - } - - private void showError(int componentId, int stringId) { - TextView field = (TextView) findViewById(componentId); - field.setError(getString(stringId)); - } - -//> INNER CLASSES - class ServerClickListener implements OnItemClickListener { - private final ServerMetadataAdapter serverMetadataAdapter; - - public ServerClickListener(ServerMetadataAdapter serverMetadataAdapter) { - this.serverMetadataAdapter = serverMetadataAdapter; - } - - public void onItemClick(AdapterView parent, final View view, int position, long id) { - ServerMetadata server = serverMetadataAdapter.getServerMetadata(position); - if (server.url == null) { - displayCustomServerForm(); - } else { - new ConfirmServerSelectionDialog( - server.name, - () -> saveSettings(new WebappSettings(server.url)) - ).show(getSupportFragmentManager()); - } - } - } - - static class ServerMetadataAdapter extends FilterableListAdapter { - private ServerMetadataAdapter(Context context, List> data) { - super( - context, - data, - R.layout.server_list_item, - new String[]{ "name", "url" }, - new int[]{ R.id.txtName, R.id.txtUrl }, - "name", "url" - ); - } - - static ServerMetadataAdapter createInstance(Context context, List servers) { - return new ServerMetadataAdapter(context, adapt(servers)); - } - - @SuppressWarnings("unchecked") - ServerMetadata getServerMetadata(int position) { - Map dataMap = (Map) this.getItem(position); - return new ServerMetadata(dataMap.get("name"), dataMap.get("url")); - } - - private static List> adapt(List servers) { - return servers - .stream() - .map(server -> { - Map serverProperties = new HashMap<>(); - serverProperties.put("name", server.name); - serverProperties.put("url", server.url); - return serverProperties; - }) - .collect(Collectors.toList()); - } - } -} - -class ServerMetadata { - public final String name; - public final String url; - - ServerMetadata(String name) { - this(name, null); - } - - ServerMetadata(String name, String url) { - trace(this, "ServerMetadata() :: name: %s, url: %s", name, redactUrl(url)); - this.name = name; - this.url = url; - } -} - -class ServerRepo { - private final SharedPreferences prefs; - private final SettingsStore settingsStore; - - ServerRepo(Context ctx, SettingsStore settingsStore) { - prefs = ctx.getSharedPreferences( - "ServerRepo", - Context.MODE_PRIVATE); - - this.settingsStore = settingsStore; - - Map instances = parseInstanceXML(ctx); - for (Map.Entry entry : instances.entrySet()) { - String instanceName = entry.getValue(); - String instanceUrl = entry.getKey(); - - save(instanceName, instanceUrl); - } - } - - List getServers() { - List servers = new LinkedList(); - - for(Map.Entry e : prefs.getAll().entrySet()) { - servers.add(new ServerMetadata( - e.getValue().toString(), - e.getKey())); - } - - Collections.sort(servers, Comparator.comparing(server -> server.name)); - - if (this.settingsStore.allowCustomHosts()) { - servers.add(0, new ServerMetadata("Custom")); - } - - return servers; - } - - void save(String url) { - save(friendly(url), url); - } - - void save(String name, String url) { - SharedPreferences.Editor ed = prefs.edit(); - ed.putString(url, name); - ed.apply(); - } - - private static Map parseInstanceXML(Context context) { - try { - HashMap result = new HashMap<>(); - - Resources resources = context.getResources(); - XmlResourceParser xmlParser = resources.getXml(R.xml.instances); - - while (xmlParser.next() != XmlPullParser.END_TAG) { - if (xmlParser.getEventType() != XmlPullParser.START_TAG - || !"instance".equals(xmlParser.getName())) { - continue; - } - String name = xmlParser.getAttributeValue(null, "name"); - String url = xmlParser.nextText(); - if (name == null) { - name = friendly(url); - } - result.put(url, name); - } - - return result; - } catch (XmlPullParserException | IOException e) { - error(e, "Failed to load instances data from xml."); - return Collections.emptyMap(); - } - } - - - @SuppressLint("DefaultLocale") - private static String friendly(String url) { - int slashes = url.indexOf("//"); - - if(slashes != -1) { - url = url.substring(slashes + 2); - } - - if(url.endsWith(".medicmobile.org")) { - url = url.substring(0, url.length() - ".medicmobile.org".length()); - } - - if(url.endsWith(".medicmobile.org/")) { - url = url.substring(0, url.length() - ".medicmobile.org/".length()); - } - - if(url.startsWith("192.168.")) { - return url.substring("192.168.".length()); - } else { - String[] parts = url.split("\\."); - StringBuilder stringBuilder = new StringBuilder(); - for(String p : parts) { - stringBuilder.append(" "); - stringBuilder.append(p.substring(0, 1).toUpperCase()); - stringBuilder.append(p.substring(1)); - } - return stringBuilder.toString().substring(1); - } - } -} diff --git a/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java b/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java index ce0480f5..d55da26c 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java @@ -1,15 +1,24 @@ package org.medicmobile.webapp.mobile; -import android.content.*; -import android.net.Uri; - -import java.util.*; -import java.util.regex.*; - import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; import static org.medicmobile.webapp.mobile.BuildConfig.TTL_LAST_URL; -import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; import static org.medicmobile.webapp.mobile.MedicLog.trace; +import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; + +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; + +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.components.ActivityComponent; +import dagger.hilt.android.qualifiers.ActivityContext; +import dagger.hilt.android.scopes.ActivityScoped; @SuppressWarnings("PMD.ShortMethodName") public abstract class SettingsStore { @@ -42,7 +51,7 @@ public boolean isRootUrl(String url) { public abstract void update(SharedPreferences.Editor ed, WebappSettings s); - void updateWith(WebappSettings s) throws SettingsException { + public void updateWith(WebappSettings s) throws SettingsException { s.validate(); SharedPreferences.Editor ed = prefs.edit(); @@ -85,7 +94,7 @@ void setLastUrl(String lastUrl) throws SettingsException { } } - static SettingsStore in(Context ctx) { + public static SettingsStore in(Context ctx) { trace(SettingsStore.class, "Loading settings for context %s...", ctx); SharedPreferences prefs = ctx.getSharedPreferences( @@ -101,6 +110,95 @@ static SettingsStore in(Context ctx) { Boolean allowCustomHosts = ctx.getResources().getBoolean(R.bool.allowCustomHosts); return new UnbrandedSettingsStore(prefs, allowCustomHosts); } + + @Module + @InstallIn(ActivityComponent.class) + public static class SettingsStoreModule { + @Provides + @ActivityScoped + public static SettingsStore provideSettingsStore(@ActivityContext Context ctx) { + return SettingsStore.in(ctx); + } + } + + public static class WebappSettings { + + public static final Pattern URL_PATTERN = Pattern.compile("http[s]?://([^/:]*)(:\\d*)?(.*)"); + + public final String appUrl; + + public WebappSettings(String appUrl) { + trace(this, "WebappSettings() :: appUrl: %s", redactUrl(appUrl)); + this.appUrl = appUrl; + } + + public void validate() throws IllegalSettingsException { + List errors = new LinkedList<>(); + + if (!isSet(appUrl)) { + errors.add(new IllegalSetting(R.id.txtAppUrl, R.string.errRequired)); + } else if (!URL_PATTERN.matcher(appUrl).matches()) { + errors.add(new IllegalSetting(R.id.txtAppUrl, R.string.errInvalidUrl)); + } + + if (!errors.isEmpty()) { + throw new IllegalSettingsException(errors); + } + } + + public void update(SharedPreferences.Editor ed, WebappSettings s) { + ed.putString("app-url", s.appUrl); + } + + private boolean isSet(String val) { + return val != null && val.length() > 0; + } + } + + public static class IllegalSetting { + + public final int componentId; + public final int errorStringId; + + public IllegalSetting(int componentId, int errorStringId) { + this.componentId = componentId; + this.errorStringId = errorStringId; + } + } + + public static class SettingsException extends Exception { + // See: https://pmd.github.io/pmd-6.36.0/pmd_rules_java_errorprone.html#missingserialversionuid + public static final long serialVersionUID = -1008287132276329302L; + + public SettingsException(String message) { + super(message); + } + } + + public static class IllegalSettingsException extends SettingsException { + + public final List errors; + + public IllegalSettingsException(List errors) { + super(createMessage(errors)); + this.errors = errors; + } + + private static String createMessage(List errors) { + if (DEBUG) { + StringBuilder bob = new StringBuilder(); + for (IllegalSetting e : errors) { + if (bob.length() > 0) { + bob.append("; "); + } + + bob.append(String.format("component[%s]: error[%s]", e.componentId, e.errorStringId)); + } + return bob.toString(); + } + return null; + } + } } @SuppressWarnings("PMD.CallSuperInConstructor") @@ -153,82 +251,3 @@ public void update(SharedPreferences.Editor ed, WebappSettings s) { ed.putString("app-url", s.appUrl); } } - -class WebappSettings { - - public static final Pattern URL_PATTERN = Pattern.compile("http[s]?://([^/:]*)(:\\d*)?(.*)"); - - public final String appUrl; - - public WebappSettings(String appUrl) { - trace(this, "WebappSettings() :: appUrl: %s", redactUrl(appUrl)); - this.appUrl = appUrl; - } - - public void validate() throws IllegalSettingsException { - List errors = new LinkedList<>(); - - if (!isSet(appUrl)) { - errors.add(new IllegalSetting(R.id.txtAppUrl, R.string.errRequired)); - } else if (!URL_PATTERN.matcher(appUrl).matches()) { - errors.add(new IllegalSetting(R.id.txtAppUrl, R.string.errInvalidUrl)); - } - - if (!errors.isEmpty()) { - throw new IllegalSettingsException(errors); - } - } - - public void update(SharedPreferences.Editor ed, WebappSettings s) { - ed.putString("app-url", s.appUrl); - } - - private boolean isSet(String val) { - return val != null && val.length() > 0; - } -} - -class IllegalSetting { - - public final int componentId; - public final int errorStringId; - - public IllegalSetting(int componentId, int errorStringId) { - this.componentId = componentId; - this.errorStringId = errorStringId; - } -} - -class SettingsException extends Exception { - // See: https://pmd.github.io/pmd-6.36.0/pmd_rules_java_errorprone.html#missingserialversionuid - public static final long serialVersionUID = -1008287132276329302L; - - public SettingsException(String message) { - super(message); - } -} - -class IllegalSettingsException extends SettingsException { - - public final List errors; - - public IllegalSettingsException(List errors) { - super(createMessage(errors)); - this.errors = errors; - } - - private static String createMessage(List errors) { - if (DEBUG) { - StringBuilder bob = new StringBuilder(); - for (IllegalSetting e : errors) { - if (bob.length() > 0) { - bob.append("; "); - } - - bob.append(String.format("component[%s]: error[%s]", e.componentId, e.errorStringId)); - } - return bob.toString(); - } - return null; - } -} diff --git a/src/main/java/org/medicmobile/webapp/mobile/Utils.java b/src/main/java/org/medicmobile/webapp/mobile/Utils.java index e4188663..dce6e246 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/Utils.java +++ b/src/main/java/org/medicmobile/webapp/mobile/Utils.java @@ -11,6 +11,7 @@ import org.json.JSONException; import org.json.JSONObject; +import org.medicmobile.webapp.mobile.components.settings_dialog.SettingsDialogActivity; import java.io.File; import java.util.Optional; diff --git a/src/main/java/org/medicmobile/webapp/mobile/adapters/ServerMetadataAdapter.java b/src/main/java/org/medicmobile/webapp/mobile/adapters/ServerMetadataAdapter.java new file mode 100644 index 00000000..9347c911 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/adapters/ServerMetadataAdapter.java @@ -0,0 +1,46 @@ +package org.medicmobile.webapp.mobile.adapters; + +import android.content.Context; + +import org.medicmobile.webapp.mobile.R; +import org.medicmobile.webapp.mobile.components.settings_dialog.ServerMetadata; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ServerMetadataAdapter extends FilterableListAdapter { + private ServerMetadataAdapter(Context context, List> data) { + super( + context, + data, + R.layout.server_list_item, + new String[]{ "name", "url" }, + new int[]{ R.id.txtName, R.id.txtUrl }, + "name", "url" + ); + } + + public static ServerMetadataAdapter createInstance(Context context, List servers) { + return new ServerMetadataAdapter(context, adapt(servers)); + } + + @SuppressWarnings("unchecked") + public ServerMetadata getServerMetadata(int position) { + Map dataMap = (Map) this.getItem(position); + return new ServerMetadata(dataMap.get("name"), dataMap.get("url")); + } + + private static List> adapt(List servers) { + return servers + .stream() + .map(server -> { + Map serverProperties = new HashMap<>(); + serverProperties.put("name", server.name); + serverProperties.put("url", server.url); + return serverProperties; + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerMetadata.java b/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerMetadata.java new file mode 100644 index 00000000..24267bf6 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerMetadata.java @@ -0,0 +1,19 @@ +package org.medicmobile.webapp.mobile.components.settings_dialog; + +import static org.medicmobile.webapp.mobile.MedicLog.trace; +import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; + +public class ServerMetadata { + public final String name; + public final String url; + + ServerMetadata(String name) { + this(name, null); + } + + public ServerMetadata(String name, String url) { + trace(this, "ServerMetadata() :: name: %s, url: %s", name, redactUrl(url)); + this.name = name; + this.url = url; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerRepo.java b/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerRepo.java new file mode 100644 index 00000000..28ae8e53 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerRepo.java @@ -0,0 +1,136 @@ +package org.medicmobile.webapp.mobile.components.settings_dialog; + +import static org.medicmobile.webapp.mobile.MedicLog.error; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; + +import org.medicmobile.webapp.mobile.R; +import org.medicmobile.webapp.mobile.SettingsStore; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import dagger.hilt.android.qualifiers.ActivityContext; +import dagger.hilt.android.scopes.ActivityScoped; + +@ActivityScoped +class ServerRepo { + private final SharedPreferences prefs; + private final SettingsStore settingsStore; + + @Inject + ServerRepo(@ActivityContext Context ctx, SettingsStore settingsStore) { + prefs = ctx.getSharedPreferences( + "ServerRepo", + Context.MODE_PRIVATE); + + this.settingsStore = settingsStore; + + Map instances = parseInstanceXML(ctx); + for (Map.Entry entry : instances.entrySet()) { + String instanceName = entry.getValue(); + String instanceUrl = entry.getKey(); + + save(instanceName, instanceUrl); + } + } + + List getServers() { + List servers = new LinkedList(); + + for (Map.Entry e : prefs.getAll().entrySet()) { + servers.add(new ServerMetadata( + e.getValue().toString(), + e.getKey())); + } + + Collections.sort(servers, Comparator.comparing(server -> server.name)); + + if (this.settingsStore.allowCustomHosts()) { + servers.add(0, new ServerMetadata("Custom")); + } + + return servers; + } + + void save(String url) { + save(friendly(url), url); + } + + void save(String name, String url) { + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(url, name); + ed.apply(); + } + + private static Map parseInstanceXML(Context context) { + try { + HashMap result = new HashMap<>(); + + Resources resources = context.getResources(); + XmlResourceParser xmlParser = resources.getXml(R.xml.instances); + + while (xmlParser.next() != XmlPullParser.END_TAG) { + if (xmlParser.getEventType() != XmlPullParser.START_TAG + || !"instance".equals(xmlParser.getName())) { + continue; + } + String name = xmlParser.getAttributeValue(null, "name"); + String url = xmlParser.nextText(); + if (name == null) { + name = friendly(url); + } + result.put(url, name); + } + + return result; + } catch (XmlPullParserException | IOException e) { + error(e, "Failed to load instances data from xml."); + return Collections.emptyMap(); + } + } + + + @SuppressLint("DefaultLocale") + private static String friendly(String url) { + int slashes = url.indexOf("//"); + + if (slashes != -1) { + url = url.substring(slashes + 2); + } + + if (url.endsWith(".medicmobile.org")) { + url = url.substring(0, url.length() - ".medicmobile.org".length()); + } + + if (url.endsWith(".medicmobile.org/")) { + url = url.substring(0, url.length() - ".medicmobile.org/".length()); + } + + if (url.startsWith("192.168.")) { + return url.substring("192.168.".length()); + } else { + String[] parts = url.split("\\."); + StringBuilder stringBuilder = new StringBuilder(); + for (String p : parts) { + stringBuilder.append(" "); + stringBuilder.append(p.substring(0, 1).toUpperCase()); + stringBuilder.append(p.substring(1)); + } + return stringBuilder.toString().substring(1); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/SettingsDialogActivity.java b/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/SettingsDialogActivity.java new file mode 100644 index 00000000..3dda9a7a --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/components/settings_dialog/SettingsDialogActivity.java @@ -0,0 +1,205 @@ +package org.medicmobile.webapp.mobile.components.settings_dialog; + +import static org.medicmobile.webapp.mobile.MedicLog.trace; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.fragment.app.FragmentActivity; + +import org.medicmobile.webapp.mobile.AppUrlVerifier; +import org.medicmobile.webapp.mobile.EmbeddedBrowserActivity; +import org.medicmobile.webapp.mobile.R; +import org.medicmobile.webapp.mobile.SettingsStore; +import org.medicmobile.webapp.mobile.SettingsStore.IllegalSetting; +import org.medicmobile.webapp.mobile.SettingsStore.IllegalSettingsException; +import org.medicmobile.webapp.mobile.SettingsStore.SettingsException; +import org.medicmobile.webapp.mobile.SettingsStore.WebappSettings; +import org.medicmobile.webapp.mobile.adapters.ServerMetadataAdapter; +import org.medicmobile.webapp.mobile.dialogs.ConfirmServerSelectionDialog; +import org.medicmobile.webapp.mobile.listeners.TextChangedListener; +import org.medicmobile.webapp.mobile.util.AsyncExecutor; + +import java.util.List; + +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint +public class SettingsDialogActivity extends FragmentActivity { + private static final int STATE_LIST = 1; + private static final int STATE_FORM = 2; + + @Inject + SettingsStore settings; + + @Inject + ServerRepo serverRepo; + + private int state; + + @Override public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + trace(this, "onCreate()"); + displayServerSelectList(); + } + +//> STATE CHANGE HANDLERS + private void displayServerSelectList() { + state = STATE_LIST; + + setContentView(R.layout.server_select_list); + + ListView list = findViewById(R.id.lstServers); + + List servers = serverRepo.getServers(); + ServerMetadataAdapter adapter = ServerMetadataAdapter.createInstance(this, servers); + list.setAdapter(adapter); + list.setOnItemClickListener(new ServerClickListener(adapter)); + + TextView seachBox = findViewById(R.id.instanceSearchBox); + seachBox.addTextChangedListener(new TextChangedListener() { + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + adapter.getFilter().filter(s.toString()); + } + }); + } + + private void displayCustomServerForm() { + state = STATE_FORM; + + setContentView(R.layout.custom_server_form); + + if(!this.settings.hasWebappSettings()) { + cancelButton().setVisibility(View.GONE); + } + + text(R.id.txtAppUrl, settings.getAppUrl()); + } + +//> EVENT HANDLERS + public void verifyAndSave(View view) { + trace(this, "verifyAndSave()"); + + submitButton().setEnabled(false); + cancelButton().setEnabled(false); + + String appUrl = text(R.id.txtAppUrl); + + 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) { + trace(this, "cancelSettingsEdit()"); + backToWebview(); + } + + @Override public void onBackPressed() { + switch(state) { + case STATE_LIST: + if(this.settings.hasWebappSettings()) { + backToWebview(); + return; + } + break; + case STATE_FORM: + displayServerSelectList(); + return; + } + super.onBackPressed(); + } + +//> PRIVATE HELPERS + private void backToWebview() { + startActivity(new Intent(this, EmbeddedBrowserActivity.class)); + finish(); + } + + private void saveSettings(WebappSettings s) { + try { + settings.updateWith(s); + this.backToWebview(); + } catch(IllegalSettingsException ex) { + trace(ex, "Tried to save illegal setting."); + for(IllegalSetting error : ex.errors) { + showError(error); + } + } catch(SettingsException ex) { + trace(ex, "Problem saving settings."); + submitButton().setError(ex.getMessage()); + } + } + + private Button cancelButton() { + return (Button) findViewById(R.id.btnCancelSettings); + } + + private Button submitButton() { + return (Button) findViewById(R.id.btnSaveSettings); + } + + private String text(int componentId) { + EditText field = (EditText) findViewById(componentId); + return field.getText().toString(); + } + + private void text(int componentId, String value) { + EditText field = (EditText) findViewById(componentId); + field.setText(value); + } + + private void showError(IllegalSetting error) { + showError(error.componentId, error.errorStringId); + } + + private void showError(int componentId, int stringId) { + TextView field = (TextView) findViewById(componentId); + field.setError(getString(stringId)); + } + +//> INNER CLASSES + class ServerClickListener implements OnItemClickListener { + private final ServerMetadataAdapter serverMetadataAdapter; + + public ServerClickListener(ServerMetadataAdapter serverMetadataAdapter) { + this.serverMetadataAdapter = serverMetadataAdapter; + } + + public void onItemClick(AdapterView parent, final View view, int position, long id) { + ServerMetadata server = serverMetadataAdapter.getServerMetadata(position); + if (server.url == null) { + displayCustomServerForm(); + } else { + new ConfirmServerSelectionDialog( + server.name, + () -> saveSettings(new WebappSettings(server.url)) + ).show(getSupportFragmentManager()); + } + } + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragmentTest.java b/src/test/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragmentTest.java index b0a0a1e2..bbffee77 100644 --- a/src/test/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragmentTest.java +++ b/src/test/java/org/medicmobile/webapp/mobile/OpenSettingsDialogFragmentTest.java @@ -21,6 +21,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.medicmobile.webapp.mobile.components.settings_dialog.SettingsDialogActivity; import org.mockito.ArgumentCaptor; import org.mockito.MockSettings; import org.mockito.MockedStatic; diff --git a/src/test/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerMetadataTest.java b/src/test/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerMetadataTest.java new file mode 100644 index 00000000..8a561593 --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/components/settings_dialog/ServerMetadataTest.java @@ -0,0 +1,60 @@ +package org.medicmobile.webapp.mobile.components.settings_dialog; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mockStatic; + +import org.junit.Test; +import org.medicmobile.webapp.mobile.MedicLog; +import org.medicmobile.webapp.mobile.SimpleJsonClient2; +import org.mockito.MockedStatic; + +public class ServerMetadataTest { + private final String NAME = "test_name"; + private final String URL = "https://test.url"; + private final String REDACTED_URL = "test.url"; + + @Test + public void construct_withUrl() { + try ( + MockedStatic medicLogMock = mockStatic(MedicLog.class); + MockedStatic jsonClientMock = mockStatic(SimpleJsonClient2.class) + ) { + jsonClientMock.when(() -> SimpleJsonClient2.redactUrl(URL)).thenReturn(REDACTED_URL); + + ServerMetadata metadata = new ServerMetadata(NAME, URL); + + assertEquals(NAME, metadata.name); + assertEquals(URL, metadata.url); + jsonClientMock.verify(() -> SimpleJsonClient2.redactUrl(URL)); + medicLogMock.verify(() -> MedicLog.trace( + any(ServerMetadata.class), + eq("ServerMetadata() :: name: %s, url: %s"), + eq(NAME), + eq(REDACTED_URL) + )); + } + } + + @Test + public void construct_withoutUrl() { + try ( + MockedStatic medicLogMock = mockStatic(MedicLog.class); + MockedStatic jsonClientMock = mockStatic(SimpleJsonClient2.class) + ) { + ServerMetadata metadata = new ServerMetadata(NAME); + + assertEquals(NAME, metadata.name); + assertNull(metadata.url); + jsonClientMock.verify(() -> SimpleJsonClient2.redactUrl((String) null)); + medicLogMock.verify(() -> MedicLog.trace( + any(ServerMetadata.class), + eq("ServerMetadata() :: name: %s, url: %s"), + eq(NAME), + eq(null) + )); + } + } +}