diff --git a/.editorconfig b/.editorconfig index 8f70d543..d9788a2e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,6 +4,9 @@ root = true charset = utf-8 end_of_line = lf insert_final_newline = true +indent_style = space +indent_size = 2 [{*.java, Makefile}] indent_style = tab +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..c8e356ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'Type: Bug' +assignees: '' + +--- + +**Important**: This is a public repository. Anyone in the world can see what's posted here. If you are posting screenshots or log files, please **carefully examine them for** the presence of any kind of **protected health information** (PHI). Images or logs containing PHI _must_ be posted in fully-redacted form, with no visible PHI. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, include the server or browser logs. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment** +- Instance: (eg: alpha.dev.medicmobile.org, etc) +- Android Version: (eg: 4.4, 11, etc) +- App Version: (eg: 0.7.3, 0.9.0, etc) + +**Additional context** +Add any other context about the problem here. What have you tried? Is there a workaround? diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..63b82974 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'Type: Feature' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/improvement.md b/.github/ISSUE_TEMPLATE/improvement.md new file mode 100644 index 00000000..fc973b89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/improvement.md @@ -0,0 +1,20 @@ +--- +name: Improvement +about: Suggest something to make an existing feature better +title: '' +labels: 'Type: Improvement' +assignees: '' + +--- + +**What feature do you want to improve?** +A clear and concise description of what the problem is. Ex. It would be better to [...] + +**Describe the improvement you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/support_request.md b/.github/ISSUE_TEMPLATE/support_request.md new file mode 100644 index 00000000..e7c4b585 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support_request.md @@ -0,0 +1,14 @@ +--- +name: Support request +about: Ask for assistance +title: '' +labels: '' +assignees: '' + +--- + +READ THIS FIRST + +The best way to get support and answers to questions is by posting on the CHT Forum as it has high engagement amongst the wider CHT community so you're more likely to get the answer you need. + +Please raise your request on the Forum: https://forum.communityhealthtoolkit.org diff --git a/.github/ISSUE_TEMPLATE/technical_issue.md b/.github/ISSUE_TEMPLATE/technical_issue.md new file mode 100644 index 00000000..89b29166 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/technical_issue.md @@ -0,0 +1,17 @@ +--- +name: Technical issue +about: Suggest an improvement users won't notice +title: '' +labels: 'Type: Technical issue' +assignees: '' + +--- + +**Describe the issue** +A clear and concise description of what the problem is. + +**Describe the improvement you'd like** +A clear and concise description of what you want to change. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. diff --git a/.github/ISSUE_TEMPLATE/z_release_major.md b/.github/ISSUE_TEMPLATE/z_release_major.md new file mode 100644 index 00000000..aed84f4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/z_release_major.md @@ -0,0 +1,53 @@ +--- +name: Major/minor release +about: Schedule a major or minor release +title: 'Release vX.Y.Z' +labels: 'Type: Internal process' +assignees: '' + +--- + +# Planning - Product Manager + +- [ ] Create a GH Milestone for the release. We use [semver](http://semver.org) so if there are breaking changes increment the major, otherwise if there are new features increment the minor, otherwise increment the service pack. Breaking changes in our case relate to updated software requirements (egs: minimum Android versions), broken backwards compatibility in an api, or a major visual update that requires user retraining. +- [ ] Add all the issues to be worked on to the Milestone. Ideally each minor release will have one or two features, a handful of improvements, and plenty of bug fixes. +- [ ] Identify any features and improvements in the release that need end-user documentation (beyond eng team documentation improvements) and create corresponding issues in the cht-docs repo +- [ ] Assign an engineer as Release Engineer for this release. + +# Development - Release Engineer + +When development is ready to begin one of the engineers should be nominated as a Release Engineer. They will be responsible for making sure the following tasks are completed though not necessarily completing them. + +- [ ] Raise a new issue called `Update dependencies for `. This should be done early in the release cycle so find a volunteer to take this on and assign it to them. +- [ ] Write an update in the weekly Product Team call agenda summarising development and acceptance testing progress and identifying any blockers. The release Engineer is to update this every week until the version is released. + +# Releasing - Release Engineer + +Once all issues have passed acceptance testing and have been merged into `master` release testing can begin. + +- [ ] Create a new release branch from `master` named `v..x`. +- [ ] Build an alpha named `v..-alpha.1` as described in the [release docs](https://docs.communityhealthtoolkit.org/core/guides/android/releasing/#alpha-for-release-testing). + - [ ] Until release testing passes, make sure regressions are fixed in `master`, cherry-pick them into the release branch, and release another alpha. +- [ ] Create a `release_notes_v..` branch from `master` and add a new section in the [CHANGELOG](https://github.com/medic/cht-android/blob/master/CHANGELOG.md). + - [ ] Ensure all issues are in the GH Milestone, that they're correctly labelled (in particular: they have the right Type, "UI/UX" if they change the UI, and "Breaking change" if appropriate), and have human readable descriptions. + - [ ] Document any known migration steps and known issues. + - [ ] Provide description, screenshots, videos, and anything else to help communicate particularly important changes. + - [ ] Document any required or recommended upgrades to our other products (eg: cht-core, cht-conf, cht-gateway). + - [ ] Assign the PR to the Director of Technology to review and confirm the documentation on upgrade instructions and breaking changes is sufficient. +- [ ] Create a release in GitHub as described in the [release docs](https://docs.communityhealthtoolkit.org/core/guides/android/releasing/#production-release). + +# Publishing - Release Engineer + +- [ ] Download the `.apk` files (or `.aab` files) from the assets on the release in GitHub for each reference flavor to publish: + - `medicmobilegamma` + - `unbranded` +- [ ] Publish a release for each flavor as described in the [publishing docs](https://docs.communityhealthtoolkit.org/core/guides/android/publishing/#google-play-store). +- [ ] Announce the release on the [CHT forum](https://forum.communityhealthtoolkit.org/c/product/releases/26), under the "Product - Releases" category using this template: +``` +*We're excited to announce the release of [{{version}}](https://github.com/medic/cht-android/releases/tag/{{version}}) of cht-android* + +New features include {{key_features}}. We've also implemented loads of other improvements and fixed a heap of bugs. + +Read the release notes for full details: {{url}} +``` +- [ ] Mark this issue "done" and close the Milestone. diff --git a/.github/ISSUE_TEMPLATE/z_release_patch.md b/.github/ISSUE_TEMPLATE/z_release_patch.md new file mode 100644 index 00000000..1b1f234e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/z_release_patch.md @@ -0,0 +1,47 @@ +--- +name: Patch release +about: Schedule a patch release +title: 'Release vX.Y.Z' +labels: 'Type: Internal process' +assignees: '' + +--- + +# Planning - Product Manager + +- [ ] Create an GH Milestone and add this issue to it. +- [ ] Add all the issues to be worked on to the Milestone. + +# Development - Release Engineer + +When development is ready to begin one of the engineers should be nominated as a Release Engineer. They will be responsible for making sure the following tasks are completed though not necessarily completing them. + +- [ ] Write an update in the weekly Product Team call agenda summarising development and acceptance testing progress and identifying any blockers. The Release Engineer is to update this every week until the version is released. + +# Releasing - Release Engineer + +Once all issues have passed acceptance testing and have been merged into `master` and backported to the release branch release testing can begin. + +- [ ] Build an alpha named `v..-alpha.1` as described in the [release docs](https://docs.communityhealthtoolkit.org/core/guides/android/releasing/#alpha-for-release-testing). + - [ ] Until release testing passes, make sure regressions are fixed in `master`, cherry-pick them into the release branch, and release another alpha. +- [ ] Create a `release_notes_v..` branch from `master` and add a new section in the [CHANGELOG](https://github.com/medic/cht-android/blob/master/CHANGELOG.md). + - [ ] Ensure all issues are in the GH Milestone, that they're correctly labelled (in particular: they have the right Type, "UI/UX" if they change the UI, and "Breaking change" if appropriate), and have human readable descriptions. + - [ ] Document any known migration steps and known issues. + - [ ] Provide description, screenshots, videos, and anything else to help communicate particularly important changes. + - [ ] Document any required or recommended upgrades to our other products (eg: cht-core, cht-conf, cht-gateway). + - [ ] Assign the PR to the Director of Technology to review and confirm the documentation on upgrade instructions and breaking changes is sufficient. +- [ ] Create a release in GitHub as described in the [release docs](https://docs.communityhealthtoolkit.org/core/guides/android/releasing/#production-release). + +# Publishing - Release Engineer + +- [ ] Download the `.apk` files (or `.aab` files) from the assets on the release in GitHub for each reference flavor to publish: + - `medicmobilegamma` + - `unbranded` +- [ ] Publish a release for each flavor as described in the [publishing docs](https://docs.communityhealthtoolkit.org/core/guides/android/publishing/#google-play-store). +- [ ] Announce the release on the [CHT forum](https://forum.communityhealthtoolkit.org/c/product/releases/26), under the "Product - Releases" category using this template: +``` +*Announcing the release of [{{version}}](https://github.com/medic/cht-android/releases/tag/{{version}}) of cht-android* + +This release fixes {{number of bugs}}. Read the release notes for full details: {{url}} +``` +- [ ] Mark this issue "done" and close the Milestone. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c44dfb8..ae4a6579 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,25 +1,35 @@ name: Build and test -on: - push: - branches: - - master - paths-ignore: - - '**.md' - pull_request: - paths-ignore: - - '**.md' +on: [push, pull_request] jobs: + skip_check: + + name: Skip Check + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@master + with: + concurrent_skipping: 'same_content_newer' + paths_ignore: '["**.md"]' + + build: name: Build - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest + needs: skip_check + if: ${{ needs.skip_check.outputs.should_skip != 'true' }} steps: - name: Checkout uses: actions/checkout@v2 + with: + submodules: true - name: Set up Java 11 uses: actions/setup-java@v2 @@ -27,6 +37,9 @@ jobs: distribution: 'adopt' java-version: '11' + - name: Test Bash Keystores scripts + run: make test-bash-keystore + - name: Test run: make test @@ -63,10 +76,14 @@ jobs: name: Instrumentation tests runs-on: macos-latest + needs: skip_check + if: ${{ needs.skip_check.outputs.should_skip != 'true' }} steps: - name: Checkout uses: actions/checkout@v2 + with: + submodules: true - name: Set up Java 11 uses: actions/setup-java@v2 @@ -102,6 +119,9 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." + - name: Test Bash Keystores scripts on MacOS + run: make test-bash-keystore + - name: Run test-ui on unbranded uses: reactivecircus/android-emulator-runner@v2 with: @@ -119,3 +139,20 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: make test-ui-gamma + + - name: Run test-ui-url + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: default + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: make test-ui-url + + - name: Archive Results + uses: actions/upload-artifact@v2 + with: + name: Test Report + path: | + build/reports/ + if: ${{ failure() }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b7cc0592..0ff29eb6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,6 +47,18 @@ jobs: ANDROID_SECRETS_KEY: ${{ secrets.ANDROID_SECRETS_KEY_ALERTE_NIGER }} ANDROID_SECRETS_IV: ${{ secrets.ANDROID_SECRETS_IV_ALERTE_NIGER }} run: make org=alerte_niger keydec + + - name: Unpack secrets chis_ne + env: + ANDROID_SECRETS_KEY: ${{ secrets.ANDROID_SECRETS_KEY_CHIS_NE }} + ANDROID_SECRETS_IV: ${{ secrets.ANDROID_SECRETS_IV_CHIS_NE }} + run: make org=chis_ne keydec + + - name: Unpack secrets cht_rci + env: + ANDROID_SECRETS_KEY: ${{ secrets.ANDROID_SECRETS_KEY_CHT_RCI }} + ANDROID_SECRETS_IV: ${{ secrets.ANDROID_SECRETS_IV_CHT_RCI }} + run: make org=cht_rci keydec - name: Assemble unbranded uses: maierj/fastlane-action@v1.4.0 @@ -170,11 +182,6 @@ jobs: with: lane: build options: '{ "flavor": "safaridoctors_kenya" }' - - name: Assemble simprints - uses: maierj/fastlane-action@v1.4.0 - with: - lane: build - options: '{ "flavor": "simprints" }' - name: Assemble surveillance_covid19_kenya uses: maierj/fastlane-action@v1.4.0 with: @@ -224,6 +231,26 @@ jobs: ANDROID_KEYSTORE_PATH: alerte_niger.keystore ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_ALERTE_NIGER }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_ALERTE_NIGER }} + - name: Assemble chis_ne + uses: maierj/fastlane-action@v1.4.0 + with: + lane: build + options: '{ "flavor": "chis_ne" }' + env: + ANDROID_KEYSTORE_PATH: chis_ne.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_CHIS_NE }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_CHIS_NE }} + + - name: Assemble cht_rci + uses: maierj/fastlane-action@v1.4.0 + with: + lane: build + options: '{ "flavor": "cht_rci" }' + env: + ANDROID_KEYSTORE_PATH: cht_rci.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_CHT_RCI }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_CHT_RCI }} + - name: Bundle alerte_niger uses: maierj/fastlane-action@v1.4.0 with: @@ -233,17 +260,33 @@ jobs: ANDROID_KEYSTORE_PATH: alerte_niger.keystore ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_ALERTE_NIGER }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_ALERTE_NIGER }} + - name: Bundle chis_ne + uses: maierj/fastlane-action@v1.4.0 + with: + lane: bundle + options: '{ "flavor": "chis_ne" }' + env: + ANDROID_KEYSTORE_PATH: chis_ne.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_CHIS_NE }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_CHIS_NE }} + + - name: Bundle cht_rci + uses: maierj/fastlane-action@v1.4.0 + with: + lane: bundle + options: '{ "flavor": "cht_rci" }' + env: + ANDROID_KEYSTORE_PATH: cht_rci.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_CHT_RCI }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_CHT_RCI }} - name: GitHub release uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: draft: true - # APKs for webview-armeabi-v7a are not uploaded files: | - build/outputs/apk/**/*-xwalk-arm64-v8a-release.apk - build/outputs/apk/**/*-xwalk-armeabi-v7a-release.apk - build/outputs/apk/**/*-webview-arm64-v8a-release.apk + build/outputs/apk/**/*-release.apk build/outputs/bundle/**/*-release.aab env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d4e933aa --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "src/test/bash/bats"] + path = src/test/bash/bats + url = https://github.com/bats-core/bats-core.git +[submodule "src/test/bash/test_helper/bats-support"] + path = src/test/bash/test_helper/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "src/test/bash/test_helper/bats-assert"] + path = src/test/bash/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 674dd10f..8db5db98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Release notes +## 0.11.0 + +### Changes + +- [feature] Remember previous URL when reloading app [#52](https://github.com/medic/cht-android/issues/52). +- [improvement] Allow users to set cht-core URLs with leading or trailing spaces (and trailing slash) on unbranded app [#178](https://github.com/medic/cht-android/issues/178). +- [improvement] Update labels to use generic app name [#128](https://github.com/medic/cht-android/issues/128). + + +### Development changes + +- [improvement] Deduplicate and improve development docs [#214](https://github.com/medic/cht-android/issues/214). + - [New `Android` section](https://docs.communityhealthtoolkit.org/core/guides/android/) added to the CHT Documentation site with the cht-android documentation that was previously split between the README and various other sections of the CHT Documentation. +- [improvement] Fix Makefile targets for keystore management [#222](https://github.com/medic/cht-android/issues/222). +- [improvement] Upgrade Gradle, plugins and test dependencies [#232](https://github.com/medic/cht-android/pull/232). + ## 0.10.0 ### Changes diff --git a/Makefile b/Makefile index a6b2c282..5c4beec8 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,15 @@ ADB = ${ANDROID_HOME}/platform-tools/adb GRADLE = ./gradlew GRADLE_OPTS = --daemon --parallel -flavor = UnbrandedWebview +flavor = Unbranded abi = x86 KEYTOOL = keytool OPENSSL = openssl JAVA = java -BASE16 = base16 +XXD = xxd RM_KEY_OPTS = -i APKSIGNER = apksigner +RANDOM_HEX = ${XXD} -p -c 256 /dev/urandom # Public key from Google for signing .pepk files (is the same for all apps in the Play Store) GOOGLE_ENC_KEY = eb10fe8f7c7c9df715022017b00c6471f8ba8170b13049a11e6c09ffe3056a104a3bbe4ac5a955f4ba4fe93fc8cef27558a3eb9d2a529a2092761fb833b656cd48b9de6a @@ -20,21 +21,20 @@ ifdef ComSpec # Windows endif default: deploy logs -xwalk: deploy-xwalk logs logs: ${ADB} logcat MedicMobile:V AndroidRuntime:E '*:S' | tee android.log deploy: ${GRADLE} ${GRADLE_OPTS} install${flavor}Debug -deploy-xwalk: - ${GRADLE} ${GRADLE_OPTS} installUnbrandedXwalkDebug + deploy-all: find build/outputs/apk -name \*-debug.apk | \ xargs -n1 ${ADB} install -r clean: ${GRADLE} clean + clean-apks: rm -rf build/outputs/apk/ @@ -42,10 +42,12 @@ assemble: check-env ANDROID_KEYSTORE_PATH=${ANDROID_KEYSTORE_PATH} ANDROID_KEY_ALIAS=${ANDROID_KEY_ALIAS} \ ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD} ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD} \ ${GRADLE} ${GRADLE_OPTS} assemble${flavor} + assemble-all: check-env ANDROID_KEYSTORE_PATH=${ANDROID_KEYSTORE_PATH} ANDROID_KEY_ALIAS=${ANDROID_KEY_ALIAS} \ ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD} ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD} \ ${GRADLE} ${GRADLE_OPTS} assembleRelease + assemble-all-debug: ${GRADLE} ${GRADLE_OPTS} assembleDebug @@ -53,6 +55,7 @@ bundle: check-env ANDROID_KEYSTORE_PATH=${ANDROID_KEYSTORE_PATH} ANDROID_KEY_ALIAS=${ANDROID_KEY_ALIAS} \ ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD} ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD} \ ${GRADLE} ${GRADLE_OPTS} bundle${flavor}Release + bundle-all: check-env ANDROID_KEYSTORE_PATH=${ANDROID_KEYSTORE_PATH} ANDROID_KEY_ALIAS=${ANDROID_KEY_ALIAS} \ ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD} ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD} \ @@ -67,22 +70,49 @@ url-tester: uninstall: adb uninstall org.medicmobile.webapp.mobile -lint: - ${GRADLE} ${GRADLE_OPTS} androidCheck lint${flavor}Debug +pmd: + ${GRADLE} ${GRADLE_OPTS} pmd + +checkstyle: + ${GRADLE} ${GRADLE_OPTS} checkstyle + +spotbugs: + ${GRADLE} ${GRADLE_OPTS} spotbugs${flavor}Debug + +lint: pmd checkstyle spotbugs + ${GRADLE} ${GRADLE_OPTS} lint${flavor}Debug + test: lint ${GRADLE} ${GRADLE_OPTS} test + +test-coverage: + ${GRADLE} ${GRADLE_OPTS} makeUnbrandedDebugUnitTestCoverageReport + test-ui: - ${GRADLE} connectedUnbrandedWebviewDebugAndroidTest -Pabi=${abi} --stacktrace + ${GRADLE} connectedUnbrandedDebugAndroidTest \ + -Pabi=${abi} --stacktrace -Pandroid.testInstrumentationRunnerArguments.class=\ + org.medicmobile.webapp.mobile.SettingsDialogActivityTest + test-ui-gamma: - ${GRADLE} connectedMedicmobilegammaWebviewDebugAndroidTest -Pabi=${abi} -- + ${GRADLE} connectedMedicmobilegammaDebugAndroidTest -Pabi=${abi} --stacktrace + +test-ui-url: + DISABLE_APP_URL_VALIDATION=true ${GRADLE} connectedUnbrandedDebugAndroidTest \ + -Pabi=${abi} --stacktrace -Pandroid.testInstrumentationRunnerArguments.class=\ + org.medicmobile.webapp.mobile.LastUrlTest + +test-ui-all: test-ui test-ui-gamma test-ui-url + +test-bash-keystore: + ./src/test/bash/bats/bin/bats src/test/bash/test-keystore.bats # # "secrets" targets, to setup and unpack keystores # -# Generate keystore -keystore: check-org ${org}.keystore +# Create the keystore, along with tokens and encrypted files needed +keygen: check-org keysetup check-keystore-exist secrets/secrets-${org}.tar.gz.enc # Remove the keystore, the pepk file, and the compressed version keyrm: check-org @@ -93,10 +123,6 @@ keyrm-all: check-org rm ${RM_KEY_OPTS} secrets/secrets-${org}.tar.gz.enc ${MAKE} keyrm -# Remove the keystore and the compressed version, leaving only the encrypted version -keyclean: check-org - rm ${RM_KEY_OPTS} ${org}.keystore secrets/secrets-${org}.tar.gz - # Print info about the keystore keyprint: check-org check-env ${KEYTOOL} -list -v -storepass ${ANDROID_KEYSTORE_PASSWORD} -keystore ${org}.keystore @@ -112,19 +138,14 @@ keyprint-bundle: $(eval AAB := $(shell find build/outputs/bundle -name \*-release.aab | head -n1)) ${KEYTOOL} -printcert -jarfile ${AAB} -keygen: check-org keysetup secrets/secrets-${org}.tar.gz.enc - keydec: check-org keysetup check-env ${OPENSSL} aes-256-cbc -iv ${ANDROID_SECRETS_IV} -K ${ANDROID_SECRETS_KEY} -in secrets/secrets-${org}.tar.gz.enc -out secrets/secrets-${org}.tar.gz -d chmod go-rw secrets/secrets-${org}.tar.gz - ${MAKE} keyunpack - -keyunpack: check-org tar -xf secrets/secrets-${org}.tar.gz keysetup: - $(eval EXEC_CERT_REQUIRED = ${JAVA} ${KEYTOOL} ${OPENSSL}) - $(info Verifing the following executables are in the $$PATH: ${EXEC_CERT_REQUIRED} ...) + $(eval EXEC_CERT_REQUIRED = ${JAVA} ${KEYTOOL} ${OPENSSL} ${XXD}) + $(info Verifying the following executables are in the $$PATH: ${EXEC_CERT_REQUIRED} ...) $(foreach exec,$(EXEC_CERT_REQUIRED),\ $(if $(shell which $(exec)),,$(error "No command '$(exec)' in $$PATH"))) @@ -163,9 +184,14 @@ ifndef ANDROID_SECRETS_IV $(eval ANDROID_KEYSTORE_PATH := $(shell echo ${${VARNAME}})) endif +check-keystore-exist: +ifneq ("$(wildcard ${org}.keystore ${org}_private_key.pepk)","") + $(error Files "${org}.keystore" or "${org}_private_key.pepk" already exist. Remove them with "make org=${org} keyrm") +endif + ${org}.keystore: check-org - $(if $(shell which $(BASE16)),,$(error "No command '$(BASE16)' in $$PATH")) - $(eval ANDROID_KEYSTORE_PASSWORD := $(shell ${BASE16} /dev/urandom | head -n 1 -c 16)) + $(if $(shell which $(XXD)),,$(error "No command '$(XXD)' in $$PATH")) + $(eval ANDROID_KEYSTORE_PASSWORD := $(shell ${RANDOM_HEX} | head -c 16)) ${KEYTOOL} -genkey -storepass ${ANDROID_KEYSTORE_PASSWORD} -v -keystore ${org}.keystore -alias medicmobile -keyalg RSA -keysize 2048 -validity 9125 chmod go-rw ${org}.keystore @@ -182,8 +208,8 @@ pepk.jar: curl https://www.gstatic.com/play-apps-publisher-rapid/signing-tool/prod/pepk.jar -o pepk.jar secrets/secrets-${org}.tar.gz.enc: secrets/secrets-${org}.tar.gz - $(eval ANDROID_SECRETS_IV := $(shell ${BASE16} /dev/urandom | head -n 1 -c 32)) - $(eval ANDROID_SECRETS_KEY := $(shell ${BASE16} /dev/urandom | head -n 1 -c 64)) + $(eval ANDROID_SECRETS_IV := $(shell ${RANDOM_HEX} | head -c 32)) + $(eval ANDROID_SECRETS_KEY := $(shell ${RANDOM_HEX} | head -c 64)) $(eval ANDROID_KEYSTORE_PATH := $(org).keystore) $(eval ANDROID_KEY_ALIAS := medicmobile) ${OPENSSL} aes-256-cbc -iv ${ANDROID_SECRETS_IV} -K ${ANDROID_SECRETS_KEY} -in secrets/secrets-${org}.tar.gz -out secrets/secrets-${org}.tar.gz.enc diff --git a/README.md b/README.md index 3a1af5d3..7732c582 100644 --- a/README.md +++ b/README.md @@ -1,278 +1,26 @@ CHT Android App =============== -The cht-android application is a thin wrapper to load the [CHT Core Framework](https://github.com/medic/cht-core/) web application in a webview. This allows the application to be hardcoded to a specific CHT deployment and have a partner specific logo and display name. This app also provides some deeper integration with other android apps and native phone functions that are otherwise unavailable to webapps. +The cht-android application is a thin Android wrapper to load the [CHT Core Framework](https://github.com/medic/cht-core/) web application in a Webview native container. -# Android App Bundles +The repository contains “flavored” configurations, where each “flavor” or “brand” is an app. This allows the application to be hardcoded to a specific CHT deployment and have a partner specific logo and display name. The app also provides some deeper integration with other android apps and native phone functions that are otherwise unavailable to webapps. -The build script produces multiple AABs for publishing to the **Google Play Store**, so the generated `.aab` files need to be uploaded instead of the `.apk` files if the Play Store require so. Old apps published for the first time before Aug 1, 2021 can be updated with the APK format. -For each flavor two bundles are generated, one for each rendering engine: _Webview_ and _Xwalk_. When distributing via the Play Store using the bundle files, upload all AABs and it will automatically choose the right one for the target device. - -The AABs are named as follows: `cht-android-{version}-{brand}-{rendering-engine}-release.aab`. - -| Rendering engine | Android version | -|------------------|-----------------| -| `webview` | 10+ | -| `xwalk` | 4.4 - 9 | - -# APKs - -For compatibility with a wide range of devices the build script produces multiple APKs. The two variables are the instruction set used by the device's CPU, and the supported Android version. When sideloading the application it is essential to pick the correct APK or the application may crash. - -If distributing APKs via the Play Store, upload all APKs and it will automatically choose the right one for the target device. - -To help you pick which APK to install you can find information about the version of Android and the CPU in the About section of the phone's settings menu. - -The APKs are named as follows: `cht-android-{version}-{brand}-{rendering-engine}-{instruction-set}-release.apk`. - -| Rendering engine | Instruction set | Android version | Notes | -|------------------|-----------------|-----------------|-------------------------------------------------------------| -| `webview` | `arm64-v8a` | 10+ | Preferred. Use this APK if possible. | -| `webview` | `armeabi-v7a` | 10+ | Built but not compatible with any devices. Ignore this APK. | -| `xwalk` | `arm64-v8a` | 4.4 - 9 | | -| `xwalk` | `armeabi-v7a` | 4.4 - 9 | | - - -# Release notes +## Release notes Checkout the release notes in the [Changelog](CHANGELOG.md) page, our you can see the full release history with the installable files for sideloading [here](https://github.com/medic/cht-android/releases). -# Development - -1. Install the [Android SDK](https://developer.android.com/studio#command-tools) and Java 11+ (it works with OpenJDK versions). -2. Clone the repository. -3. Plug in your phone. Check it's detected with `adb devices`. -4. Execute: `make` (will also push app into the phone). Use `make xwalk` for the Xwalk version instead. - -Gradle is also used but it's downloaded and installed in the user space the first time `make` is executed. - -You can also build and launch the app with [Android Studio](#android-studio). - -## Flavor selection - -Some `make` targets support the flavor and the rendering engine as `make flavor=[Flavor][Engine] [task]`, where `[Flavor]` is the branded version with the first letter capitalized and the `[Engine]` is either `Webview` or `Xwalk`. The `[task]` is the action to execute: `deploy`, `assemble`, `lint`, etc. - -The default value for `flavor` is `UnbrandedWebview`, e.g. executing `make deploy` will assemble and install that flavor, while executing `make flavor=MedicmobilegammaXwalk deploy` will do the same for the _Medicmobilegamma_ brand and the `Xwalk` engine. - -See the [Makefile](./Makefile) for more details. - -## Build and assemble - - $ make assemble - -The command above builds and assembles the _debug_ and _release_ APKs of the Unbranded Webview version of the app. - -Each APK will be generated and stored in `build/outputs/apk/[flavor][Engine]/[debug|release]/`, for example after assembling the _Simprints Webview_ flavor with `make flavor=SimprintsWebview assemble`, the _release_ versions of the APKs generated are stored in `build/outputs/apk/simprintsWebview/release/`. - -To assemble other flavors, use the following command: `make flavour=[Flavor][Engine] assemble`. See the [Flavor selection](#flavor-selection) section for more details about `make` commands. - -To create the `.aab` bundle file, use `make bundle`, although signed versions are generated when [releasing](#releasing), and the Play Store requires the AAB to be signed with the right key. - -To clean the APKs and compiled resources: `make clean`. - -## Static checks - -To only execute the **linter checks**, run: `make lint`. To perform the same checks for the _XView_ source code, use: `make flavor=UnbrandedXwalk lint` instead. - -## Testing - -To execute unit tests: `make test` (static checks ara also executed). - -### Instrumentation Tests (UI Tests) - -These tests run on your device. - -1. Uninstall previous versions of the app, otherwise an `InstallException: INSTALL_FAILED_VERSION_DOWNGRADE` can make the tests fail. -2. Select English as default language in the app. -3. Execute steps 1 to 3 from [Development](#development). -4. Execute: `make test-ui` or `make test-ui-gamma`. - -### Connecting to the server locally - -Refer to the [CHT Core Developer Guide](https://github.com/medic/cht-core/blob/master/DEVELOPMENT.md#testing-locally-with-devices). - -## Android Studio - -The [Android Studio](https://developer.android.com/studio) can be used to build and launch the app instead. Be sure to select the right flavor and rendering engine from the _Build Variants_ dialog (see [Build and run your app](https://developer.android.com/studio/run)). To launch the app in an emulator, you need to uncomment the code that has the strings for the `x86` or the `x86_64` architecture in the `android` / `splits` / `include` sections of the `build.gradle` file. - - -# Branding - -Starting Aug 2021 Google [changed](https://developer.android.com/guide/app-bundle) the way new apps are published in the Play Store, so older apps in this repo only need to upload the release APK files to update the app, while apps created after Aug 2021 do so uploading the AAB files. Moreover, the creation of the App requires not just the basic configurations like the AAB files, name of the app, description and so on, but also requires to register the key that we use to sign the AAB files, so Google can create optimized APK versions for the users signed with the same key the .aab files were created. - -In summary the steps for a new app are: - -1. Setup the new flavor in the source code and in the CI pipeline to build and sign the app when releasing (next section). -2. Choose a keystore under the [secrets/](secrets/) folder to sign the new app (next section), the old keystore used by medic (`secrets-medic.tar.gz.enc`) cannot be used to create new apps, is only used to keep signing the old apps. Each partner usually publish one app and should has its own keystore, although in case it has more than one app the same keystore can be used. If the partner doesn't have its own keystore, follow the instructions in the next section. -3. With the first version created after tagging and releasing in Github, go to the Play Store Console and create the app, uploading the first .aab files along with the key file. - -### New brand - -These are the steps to create a new branded App. Each branded app has an identifier that is used to identify and configure it in different parts of the source code and when invoking some commands. In the instructions below we will use as example the id `new_brand`. - -1. Add `productFlavors { { ... } }` in `build.gradle`, e.g.: - - ```groovy - new_brand { - dimension = 'brand' - applicationId = 'org.medicmobile.webapp.mobile.new_brand' - } - ``` - -2. Add icons, strings etc. in the `src/` folder. It's required to place there at least the `src/new_brand/res/values/strings.xml` file with the name of the app and the URL of the CHT instance: - - ```xml - - - New Brand - new_brand.app.medicmobile.org - - ``` - -3. Enable automated builds of the APKs and AABs: add the `new_brand` flavor in `.github/workflows/publish.yml`. The _Unpack secrets ..._ task unpacks and decrypts the secret file with the keystore, The _Assemble ..._ task takes care of generating the `.apk` files for sideloading, and the _Bundle ..._ task is responsible of generating the `.aab` files for publishing in the Play Store: - - ```yml - - name: Unpack secrets new_brand - env: - ANDROID_SECRETS_KEY: ${{ secrets.ANDROID_SECRETS_KEY_NEW_BRAND }} - ANDROID_SECRETS_IV: ${{ secrets.ANDROID_SECRETS_IV_NEW_BRAND }} - run: make org=new_brand keydec - - - name: Assemble new_brand - uses: maierj/fastlane-action@v1.4.0 - with: - lane: build - options: '{ "flavor": "new_brand" }' - env: - ANDROID_KEYSTORE_PATH: new_brand.keystore - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_NEW_BRAND }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_NEW_BRAND }} - - - name: Bundle new_brand - uses: maierj/fastlane-action@v1.4.0 - with: - lane: bundle - options: '{ "flavor": "new_brand" }' - env: - ANDROID_KEYSTORE_PATH: new_brand.keystore - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD_NEW_BRAND }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD_NEW_BRAND }} - ``` - - The variables in the `env` sections point to a keystore and the passwords to unlock the keystore that will be generated in the following steps, but it's important to follow the name convention, in the example all the variables that are configured in Github CI end with the suffix `_NEW_BRAND`, these variables need to be added in the cht-android repo settings by a manager of Medic. - -4. Generate the keystore: the keystore files are placed into a compressed and encrypted file in the [secrets/](secrets/) folder. In our case the file will be `secrets/secrets-new_brand.tar.gz.enc`, and the content inside when the file is decrypted is: - - - `new_brand.keystore`: the Java keystore with a signature key inside that is always called `medicmobile`. It's used to sign the APKs and the bundles, and the one that Google will use to sign the optimized APKs that generates in the Play Store. - - `new_brand_private_key.pepk`: a PEPK file is an encrypted file that contains inside the `medicmobile` key from the keystore above, ready to be uploaded to the Play Store the first time the app is registered in the Play Console. The file is only used there, but kept in the compressed file as a backup. - - Don't worry to follow all the name conventions and how to generate these files, you can create a new keystore, the passwords and the PEPK file with only one command: `make org=new_brand keygen`. Executing the command will check that you have the necessary tooling installed, and ask you the information about the certificate like the organization name, organization unit, etc. The command also takes care of picking random passwords that meet the security requirements, and then compresses the key files and finally encrypt the `.tar.gz` file into the `.enc` file. At the end of the execution, the script will also show the list of environment variables that you have to setup in CI and locally in order to sign apps with the new keystore. - - ``` - $ make org=new_brand keygen - Verifing the following executables are in the $PATH: java keytool openssl ... - keytool -genkey -storepass dd8668... -v -keystore new_brand.keystore -alias medicmobile -keyalg RSA -keysize 2048 -validity 9125 - What is your first and last name? - [Unknown]: - What is the name of your organizational unit? - [Unknown]: New Brand - What is the name of your organization? - [Unknown]: Medic Mobile - What is the name of your City or Locality? - [Unknown]: San Fran... ... - Is CN=Unknown, OU=New Brand, O=Medic Mobile, L=San Francisco, ST=CA, C=US correct? - [no]: y - - Generating 2,048 bit RSA key pair and self-signed certificate (SHA256withRSA) with a validity of 9,125 days - for: CN=Unknown, OU=New Brand, O=Medic Mobile, L=San Francisco, ST=CA, C=US - [Storing new_brand.keystore] - ... ... - - ####################################### Secrets! ####################################### - # # - # The following environment variables needs to be added to the CI environment # - # (Github Actions), and to your local environment if you also want # - # to sign APK or AAB files locally: # - # # - - export ANDROID_KEYSTORE_PASSWORD_NEW_BRAND=dd8668... - export ANDROID_KEY_PASSWORD_NEW_BRAND=dd8668... - export ANDROID_SECRETS_IV_NEW_BRAND=88d9c2dea7a9... - export ANDROID_SECRETS_KEY_NEW_BRAND=2824d02d2bc221f5844b8fe1d928211dcbbc... - export ANDROID_KEYSTORE_PATH_NEW_BRAND=new_brand.keystore - export ANDROID_KEY_ALIAS_NEW_BRAND=medicmobile - - # - # The file secrets/secrets-new_brand.tar.gz.enc was created and has to be added to the git - # repository (don't worry, it's encrypted with some of the keys above). # - # NOTE: *keep the environment variables secret !!* # - # # - ########################################### End of Secrets ################################### - ``` - - The _Secrets!_ section at the end is as important as the `secrets/secrets-new_brand.tar.gz.enc` file generated, because as it says above, it needs to be configured in CI. - - Use a safe channel to send to the manager in charge, like a password manager, and keep them locally at least for testing, storing in a script file that is safe in your computer. - - If you want to start over because some of the parameters were wrong, just execute `make org=new_brand keyrm-all` to clean all the files generated. Once committed the `.enc` file, you can delete the uncompressed and unencrypted version with `make org=new_brand keyrm`, it will delete the `new_brand.keystore`, `new_brand_private_key.pepk`, and the unencrypted `.tar.gz` files, that are safer kept in the `.tar.gz.enc` file. - - To decrypt the content like CI does to sign the app, execute: `make org=new_brand keydec`, it will decrypt and decompress the files removed in the step above. Remember that the environment variables printed in the console needs to be loaded in the CLI. Note that all the variables above end with the suffix `_NEW_BRAND`, as the id of the app that we pass through the `org` argument in lowercase, but if Make found the same variables defined without the prefix, they take precedence over the suffix ones. - -#### Use the keystore - -**Want to check the keystore?** here are a few things you must test before upload to the repo: - -1. Execute `make org=new_brand keyprint` to see the certificate content, like the org name, the certificate fingerprints, etc. - -2. Sign your app! try locally to build the app with the certificate. To create the Webview versions of the .apk files: `make org=new_brand flavor=New_brandWebview assemble`. the "release" files signed should be placed in `build/outputs/apk/new_brandWebview/release/`. To ensure the files were signed with the right signature execute `make keyprint-apk`, it will check the certificate of the first apk file under the `build/` folder: - -``` -$ make keyprint-apk -apksigner verify -v --print-certs build/outputs/apk/new_brandWebview/release/cht-android-SNAPSHOT-new_brand-webview-armeabi-v7a-release.apk -... ... -Verified using v2 scheme (APK Signature Scheme v2): true -... ... -Signer #1 certificate DN: CN=Unknown, OU=New Brand, O=Medic Mobile, L=San Francisco, ST=CA, C=US -Signer #1 certificate SHA-256 digest: 7f072b... -``` - -Also do the same for the bundle format: build and verify, despite the AAB are not useful for local development. In our example, execute first `make org=new_brand flavor=New_brandWebview bundle`, and then `make keyprint-bundle` to see the signature of one of the `.aab` files generated. - -Because the files generated here are signed with the same key that you are going to use in CI, and the files produced in CI will be uploaded to the Play Store later, any file generated locally following the steps above will be compatible with any installation made from the Play Store, means that if a user install the app from the Play Store, and then we want to replace the installation with an alpha version generated in CI or a local version generated in dev environment, it will work without requiring the user to uninstall the app and lost the data. - - -# Releasing - -These are the steps to follow when creating a new release in the Play Store: - -## Alpha for release testing - -1. Make sure all issues for this release have passed AT and been merged into `master`. -2. Create a git tag starting with `v` and ending with the alpha version, e.g. `v1.2.3-alpha.1` and push the tag to GitHub. -3. Creating this tag will trigger [GitHub Action](https://github.com/medic/cht-android/actions) to build, sign, and properly version the build. The release-ready APKs are available for side-loading from [GitHub Releases](https://github.com/medic/cht-android/releases), along with the AABs that may be required by the Google Play Store. -4. Announce the release in #quality-assurance. - - -### New App in the Play Store - -Remember that when the app is created in the Play Store, it's required to choose the way the app will be signed by Google: we upload the signed AAB files, but then Google creates optimized versions of the app in .apk format. The app has to be configured to use the same signing and upload signatures by Google. Choose to upload a "Java keystore", the Play Console will require a file encrypted with a tool named PEPK, that file is the `_private_key.pepk` file generated when following the instructions of [New brand](#new-brand) (the button to upload the `.pepk` in the Play Console may say "Upload generated ZIP" although the PEPK file doesn't look like a .zip file). Read the section to know how to extract that file from the encrypted file stored in the `secrets/` folder if you don't have it when publishing for the first time. Once upload the first time, you don't need it anymore in order to publish new versions of the app. - -## Final for users +## Development -1. Create a git tag starting with `v`, e.g. `v1.2.3` and push the tag to GitHub. -2. The exact same process as Step 3 above. -3. Publish the unbranded, simprints, and gamma flavors to the Play Store. -4. Announce the release on the [CHT forum](https://forum.communityhealthtoolkit.org), under the "Product - Releases" category. -5. Each flavor is then individually released to users via "Release Management" in the Google Play Console. Once a flavor has been tested and is ready to go live, click Release to Production. +Development guides are available in the "Android" section of the [Community Health Toolkit Docs Site](https://docs.communityhealthtoolkit.org/core/guides/android/). You will find instructions of how to setup your development environment, build and test new features, creates new branded apps, release, publish... and so on. -# Copyright +## Copyright -Copyright 2013-2021 Medic Mobile, Inc. . +Copyright 2013-2022 Medic Mobile, Inc. . -# License +## License The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license. diff --git a/build.gradle b/build.gradle index 9c9f5d7b..4080fa48 100644 --- a/build.gradle +++ b/build.gradle @@ -1,17 +1,21 @@ buildscript { repositories { mavenCentral() - maven { url "https://jitpack.io" } + maven { url 'https://jitpack.io' } + maven { url 'https://plugins.gradle.org/m2/' } google() - jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.2' - classpath 'com.noveogroup.android:check:1.2.5' + classpath 'com.android.tools.build:gradle:7.1.1' + classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.5' } } + apply plugin: 'com.android.application' -apply plugin: 'com.noveogroup.android.check' +apply plugin: 'checkstyle' +apply plugin: 'pmd' +apply plugin: 'com.github.spotbugs' +apply from: 'coverage.gradle' // enable verbose lint warnings gradle.projectsEvaluated { @@ -20,18 +24,44 @@ gradle.projectsEvaluated { } } +task checkstyle(type: Checkstyle) { + description 'Check code standard' + group 'verification' + configFile file('config/checkstyle.xml') + source 'src' + classpath = files() + ignoreFailures = false +} + +task pmd(type: Pmd) { + ruleSetFiles = files('config/pmd.xml') + ruleSets = [] + ignoreFailures = false + source 'src' +} + +spotbugs { + ignoreFailures = false + showStackTraces = true + showProgress = true + effort = 'default' + reportLevel = 'default' + maxHeapSize = '1g' + omitVisitors = [ + 'FindReturnRef' // This app exchanges data with external applications, we assume is safe. + ] + onlyAnalyze = [ 'org.medicmobile.webapp.mobile.*' ] +} + repositories { mavenCentral() - jcenter() - maven { url "https://jitpack.io" } + maven { url 'https://jitpack.io' } google() flatDir { dirs 'libs' } } -def simprintsApiKey, simprintsModuleId, simprintsUserId - def getVersionCode = { int versionCode = 2 if (System.env.CI == 'true' && System.env.RELEASE_VERSION && System.env.RELEASE_VERSION.startsWith('v')) { @@ -41,7 +71,7 @@ def getVersionCode = { throw new RuntimeException("Unexpected version number - should be of formatted as 'v1.2.3' or 'v1.2.3-alpha.4', but was: $System.env.RELEASE_VERSION") versionParts = versionParts.drop(1).collect { Integer.parseInt(it) } - int alphaPart = versionParts.size() == 4 ? versionParts[3] : 99; + int alphaPart = versionParts.size() == 4 ? versionParts[3] : 99 if (versionParts[1] > 99 || versionParts[2] > 99 || alphaPart > 99) throw new RuntimeException('Version part greater than 99 not allowed.') @@ -66,11 +96,13 @@ android { } defaultConfig { + //noinspection OldTargetApi + targetSdkVersion 30 + minSdkVersion 21 // Android 5.0 + versionCode getVersionCode() versionName getVersionName() archivesBaseName = "${project.name}-${versionName}" - // When upgrading targetSdkVersion, check that the app menu still works on newer devices. - targetSdkVersion 30 - // For espresso tests + //For espresso tests testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Test user credentials buildConfigField "String", "TEST_USERNAME", "\"${System.env.ANDROID_TEST_USERNAME}\"" @@ -81,9 +113,6 @@ android { } else { buildConfigField "String", "SERVER_URL", '"https://gamma-cht.dev.medicmobile.org"' } - - // Required when setting minSdkVersion to 20 or lower (xwalk minSdkVersion is 19). - multiDexEnabled true } compileOptions { @@ -94,37 +123,14 @@ android { } applicationVariants.all { variant -> - buildConfigField "boolean", "DISABLE_APP_URL_VALIDATION", "Boolean.parseBoolean(\"${System.env.DISABLE_APP_URL_VALIDATION}\")"; - buildConfigField "String", "LOG_TAG", '"MedicMobile"' - - if (System.env.SIMPRINTS_API_KEY) { - buildConfigField "String", "SIMPRINTS_API_KEY", /"${System.env.SIMPRINTS_API_KEY}"/ - } else if (simprintsApiKey) { - buildConfigField "String", "SIMPRINTS_API_KEY", /"${simprintsApiKey}"/ - } else { - buildConfigField "String", "SIMPRINTS_API_KEY", /"Medic's API Key"/ - } - - if (System.env.SIMPRINTS_USER_ID) { - buildConfigField "String", "SIMPRINTS_USER_ID", /"${System.env.SIMPRINTS_USER_ID}"/ - } else if (simprintsUserId) { - buildConfigField "String", "SIMPRINTS_USER_ID", /"${simprintsUserId}"/ - } else { - buildConfigField "String", "SIMPRINTS_USER_ID", '"some-user-id"' - } - - if (System.env.SIMPRINTS_MODULE_ID) { - buildConfigField "String", "SIMPRINTS_MODULE_ID", /"${System.env.SIMPRINTS_MODULE_ID}"/ - } else if (simprintsModuleId) { - buildConfigField "String", "SIMPRINTS_MODULE_ID", /"${simprintsModuleId}"/ - } else { - buildConfigField "String", "SIMPRINTS_MODULE_ID", '"Medic Module ID"' - } + buildConfigField "boolean", "DISABLE_APP_URL_VALIDATION", "Boolean.parseBoolean(\"${System.env.DISABLE_APP_URL_VALIDATION}\")" + buildConfigField "String", "LOG_TAG", '"MedicMobile"' + buildConfigField "Long", "TTL_LAST_URL", '24l * 60 * 60 * 1000' // 24 hs max time last URL loaded is remembered // Every APK requires a unique version code. // So when compiling multiple APKS for the different ABIs, use the first digit variant.outputs.each { output -> - def versionAugmentation = (output.getFilter(com.android.build.OutputFile.ABI) == 'arm64-v8a') ? 1 : 0; + def versionAugmentation = (output.getFilter(com.android.build.OutputFile.ABI) == 'arm64-v8a') ? 1 : 0 output.versionCodeOverride = variant.versionCode * 10 + versionAugmentation } } @@ -144,23 +150,18 @@ android { } release { minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'config/xwalk.pro', 'config/libsimprints.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') shrinkResources true signingConfig signingConfigs.release } } - check { - abortOnError true - } - lintOptions { lintConfig = new File('config/lint.xml') disable 'UnusedResources' // linter can't handle static imports, so just skip this test disable 'MissingTranslation' disable 'StringFormatCount' - disable 'JcenterRepositoryObsolete' warningsAsErrors true @@ -174,38 +175,27 @@ android { } } - flavorDimensions 'brand', 'version' - productFlavors { - - webview { - dimension 'version' - minSdkVersion 29 // Android 10 - // the APK with a higher minSdkVersion value must have a higher versionCode value - // https://developer.android.com/google/play/publishing/multiple-apks - versionCode getVersionCode() + 1; - versionNameSuffix '-webview' + testOptions { + unitTests { + includeAndroidResources = true } + } - xwalk { - dimension 'version' - minSdkVersion 19 // Android 4.4 - versionCode getVersionCode(); - versionNameSuffix '-xwalk' - } + flavorDimensions 'brand' + productFlavors { unbranded { // we will not create project-specific src directories // for `unbranded` - it will use the defaults in // src/main dimension = 'brand' - simprintsApiKey = 'f4c47c4e-d6ee-444f-b16e-22a4761b1f3c' - simprintsModuleId = 'simprints.app' - simprintsUserId = 'test@simprints.app' } + medicmobiledemo { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.medicmobiledemo' } + medicmobilegamma { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.medicmobilegamma' @@ -215,6 +205,7 @@ android { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.bracuganda' } + cic_guatemala { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.cic_guatemala' @@ -229,106 +220,122 @@ android { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.ebpp_indonesia' } + hope_through_health { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.hope_through_health' } + livinggoods { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.livinggoods' } + livinggoodskenya { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.livinggoodskenya' } + livinggoods_assisted_networks { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.livinggoods_assisted_networks' } + livinggoods_innovation_ke_supervisor { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.livinggoods_innovation_ke_supervisor' } + livinggoods_innovation_ke_hivst { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.livinggoods_innovation_ke_hivst' } + moh_kenya_siaya_white { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_kenya_siaya' } + moh_kenya_siaya_red { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_kenya_siaya_red' } + moh_kenya_siaya_green { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_kenya_siaya_green' } + moh_kenya_siaya_black { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_kenya_siaya_black' } + moh_mali { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_mali' } + moh_zanzibar_training { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_zanzibar_training' } + moh_zanzibar { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.moh_zanzibar' } + musomali { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.musomali' } + pih_malawi { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.pih_malawi' } + pih_malawi_supervisor { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.pih_malawi_supervisor' } + safaridoctors_kenya { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.safaridoctors_kenya' } - simprints { - dimension = 'brand' - applicationId = 'org.medicmobile.webapp.mobile.simprints' - simprintsApiKey = 'f4c47c4e-d6ee-444f-b16e-22a4761b1f3c' - simprintsModuleId = 'simprints.app' - simprintsUserId = 'test@simprints.app' - } vhw_burundi { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.vhw_burundi' } + surveillance_covid19_kenya { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.surveillance_covid19_kenya' } + trippleeighty { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.trippleeighty' } + covid_moh_mali { dimension = 'brand' applicationId = "org.medicmobile.webapp.mobile.covid_moh_mali" } + icm_ph_chc { dimension = 'brand' applicationId = "org.medicmobile.webapp.mobile.icm_ph_chc" } + vhtapp_uganda { dimension = 'brand' applicationId = "org.medicmobile.webapp.mobile.vhtapp_uganda" } + unbranded_test { dimension = 'brand' applicationId = "org.medicmobile.webapp.mobile.unbranded_test" @@ -338,14 +345,27 @@ android { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.itech_aurum' } + itech_malawi { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.itech_malawi' } + alerte_niger { dimension = 'brand' applicationId = 'org.medicmobile.webapp.mobile.alerte_niger' } + + chis_ne { + dimension = 'brand' + applicationId = 'org.medicmobile.webapp.mobile.chis_ne' + } + + cht_rci { + dimension = 'brand' + applicationId = 'org.medicmobile.webapp.mobile.cht_rci' + } + } splits { @@ -361,29 +381,26 @@ android { universalApk false } } - - sourceSets { - xwalk { - jniLibs.srcDirs = ['src/xwalk/libs'] - } - } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'com.simprints:LibSimprints:1.0.11' + compileOnly 'com.github.spotbugs:spotbugs-annotations:4.5.3' implementation 'com.github.Mariovc:ImagePicker:1.2.2' + // Latest version of androidx.core requires Android 12+ + // noinspection GradleDependency + implementation 'androidx.core:core:1.6.0' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' - xwalkImplementation files('src/xwalk/libs/xwalk_core_library-23.53.589.4-arm64-v8a.aar') - testImplementation 'junit:junit:4.13.1' - testImplementation 'org.mockito:mockito-inline:3.11.2' + 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.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' + 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.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.0-alpha03' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:core:1.4.0' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'org.hamcrest:hamcrest-library:2.2' } diff --git a/config/checkstyle.xml b/config/checkstyle.xml index 05e7ab10..931704aa 100644 --- a/config/checkstyle.xml +++ b/config/checkstyle.xml @@ -8,7 +8,7 @@ - + @@ -17,9 +17,7 @@ - - - + @@ -31,7 +29,7 @@ - + @@ -78,12 +76,8 @@ - - - - - - + + @@ -106,9 +100,7 @@ - - - + diff --git a/config/findbugs.xml b/config/findbugs.xml deleted file mode 100644 index 80afe93f..00000000 --- a/config/findbugs.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/config/libsimprints.pro b/config/libsimprints.pro deleted file mode 100644 index d334fee2..00000000 --- a/config/libsimprints.pro +++ /dev/null @@ -1,3 +0,0 @@ --keep class com.simprints.libsimprints.* implements android.os.Parcelable { - *; -} diff --git a/config/pmd.xml b/config/pmd.xml index 4f1e3aec..f17c8e1c 100644 --- a/config/pmd.xml +++ b/config/pmd.xml @@ -1,117 +1,31 @@ - - - - - - POM rule set file - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + PMD Rule Set + + + + + + + + + + + + + + + + + + + + + diff --git a/config/xwalk.pro b/config/xwalk.pro deleted file mode 100644 index 581d5e72..00000000 --- a/config/xwalk.pro +++ /dev/null @@ -1,23 +0,0 @@ -# ProGuard config for Crosswalk - --dontwarn javax.annotation.Nullable --dontwarn javax.annotation.Nonnull --dontwarn javax.annotation.concurrent.NotThreadSafe --dontwarn javax.annotation.concurrent.ThreadSafe - --keep class org.xwalk.core.** { - *; -} --keep class org.chromium.** { - *; -} --keepattributes ** - --optimizations !code/allocation/variable - --dontnote android.support.** - --keepclassmembers class * { - @android.webkit.JavascriptInterface ; - @org.xwalk.core.JavascriptInterface ; -} diff --git a/coverage.gradle b/coverage.gradle new file mode 100644 index 00000000..05c2ee61 --- /dev/null +++ b/coverage.gradle @@ -0,0 +1,47 @@ +apply plugin: 'jacoco' + +tasks.withType(Test) { + jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] +} + +project.afterEvaluate { + def variantName = 'unbrandedDebug' + def testType = "${variantName.capitalize()}UnitTest" + def testTaskName = "test${testType}" + + tasks.create(name: "make${testType}CoverageReport", type: JacocoReport, dependsOn: testTaskName) { + group = 'Reporting' + description = "Generate Jacoco coverage report for the ${variantName} build." + + reports { + html.enabled(true) + } + + def excludes = [ + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + 'android/**/*.*', + 'androidx/**/*.*', + '**/*_Factory.*', + '**/*_Provide*Factory*.*', + '**/*_ViewBinding*.*', + '**/AutoValue_*.*', + '**/R2.class', + '**/R2$*.class', + '**/*Directions$*', + '**/*Directions.*', + '**/*Binding.*' + ] + + classDirectories.from = files(fileTree( + dir: "${project.buildDir}/intermediates/javac/${variantName}/classes", + excludes: excludes + )) + sourceDirectories.from = file("${project.projectDir}/src/main/java") + executionData.from = file("${project.buildDir}/outputs/unit_test_code_coverage/${testType}/${testTaskName}.exec") + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2c0e1ac2..b216516f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -6,13 +6,11 @@ default_platform(:android) platform :android do lane :build do |options| - gradle(task: "assemble#{options[:flavor]}WebviewRelease") - gradle(task: "assemble#{options[:flavor]}XwalkRelease") + gradle(task: "assemble#{options[:flavor]}Release") end lane :bundle do |options| - gradle(task: "bundle#{options[:flavor]}WebviewRelease") - gradle(task: "bundle#{options[:flavor]}XwalkRelease") + gradle(task: "bundle#{options[:flavor]}Release") end lane :deploy do |options| @@ -24,11 +22,9 @@ platform :android do track: "alpha", json_key: "playstore-secret.json", apk_paths: [ - "build/outputs/apk/#{options[:flavor]}Webview/release/cht-android-#{version}-#{options[:flavor]}-webview-arm64-v8a-release.apk", - # NOT SUPPORTED WITH: targetSdkVersion 29. Need to add it back to support older Android versions - # "build/outputs/apk/#{options[:flavor]}Webview/release/cht-android-#{version}-#{options[:flavor]}-webview-armeabi-v7a-release.apk", - "build/outputs/apk/#{options[:flavor]}Xwalk/release/cht-android-#{version}-#{options[:flavor]}-xwalk-arm64-v8a-release.apk", - "build/outputs/apk/#{options[:flavor]}Xwalk/release/cht-android-#{version}-#{options[:flavor]}-xwalk-armeabi-v7a-release.apk", + "build/outputs/apk/#{options[:flavor]}/release/cht-android-#{version}-#{options[:flavor]}-arm64-v8a-release.apk", + # Support for older versions of Android: + "build/outputs/apk/#{options[:flavor]}/release/cht-android-#{version}-#{options[:flavor]}-armeabi-v7a-release.apk", ], skip_upload_aab: true, skip_upload_metadata: true, @@ -48,8 +44,7 @@ platform :android do track: "alpha", json_key: "playstore-secret.json", aab_paths: [ - "build/outputs/bundle/#{options[:flavor]}WebviewRelease/cht-android-#{version}-#{options[:flavor]}-webview-release.aab", - "build/outputs/bundle/#{options[:flavor]}XwalkRelease/cht-android-#{version}-#{options[:flavor]}-xwalk-release.aab", + "build/outputs/bundle/#{options[:flavor]}Release/cht-android-#{version}-#{options[:flavor]}-release.aab", ], skip_upload_apk: true, skip_upload_metadata: true, diff --git a/gradle.properties b/gradle.properties index f991a87d..07f6f783 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,4 @@ android.useAndroidX=true +# Remove 'android.enableJetifier=true' when dropping the library: com.github.Mariovc:ImagePicker. +android.enableJetifier=true org.gradle.jvmargs=-Xmx2048m diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 05679dc3..e750102e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 744e882e..1b6c7873 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MSYS* | MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/secrets/secrets-chis_ne.tar.gz.enc b/secrets/secrets-chis_ne.tar.gz.enc new file mode 100644 index 00000000..426832f8 Binary files /dev/null and b/secrets/secrets-chis_ne.tar.gz.enc differ diff --git a/secrets/secrets-cht_rci.tar.gz.enc b/secrets/secrets-cht_rci.tar.gz.enc new file mode 100644 index 00000000..fc96b38d Binary files /dev/null and b/secrets/secrets-cht_rci.tar.gz.enc differ diff --git a/secrets/secrets-cht_rci_test.tar.gz.enc b/secrets/secrets-cht_rci_test.tar.gz.enc new file mode 100644 index 00000000..78e2603f Binary files /dev/null and b/secrets/secrets-cht_rci_test.tar.gz.enc differ diff --git a/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/LastUrlTest.java b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/LastUrlTest.java new file mode 100644 index 00000000..5b1f71d9 --- /dev/null +++ b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/LastUrlTest.java @@ -0,0 +1,107 @@ +package org.medicmobile.webapp.mobile; + +import static androidx.test.espresso.Espresso.onData; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches; +import static androidx.test.espresso.web.sugar.Web.onWebView; +import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.getText; +import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +import androidx.test.espresso.DataInteraction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.web.webdriver.Locator; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; + + +/** + * Test that when the app is closed and then opened again, the last URL + * viewed is loaded instead of the app URL. + */ +@LargeTest +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@RunWith(AndroidJUnit4.class) +public class LastUrlTest { + + @Rule + public ActivityScenarioRule mActivityTestRule = + new ActivityScenarioRule<>(SettingsDialogActivity.class); + + @Test + public void testOpenUrlAndRecord() throws InterruptedException { + onView(withText("Custom")).perform(click()); + ViewInteraction textAppUrl = onView(withId(R.id.txtAppUrl)); + // Load the the Angular "Getting started" guide + textAppUrl.perform(replaceText("https://angular.io/start"), closeSoftKeyboard()); + onView(withId(R.id.btnSaveSettings)).perform(click()); + Thread.sleep(2000); + // Check section content is loaded + onWebView() + .withNoTimeout() + .withElement(findElement(Locator.TAG_NAME, "h1")) + .check(webMatches(getText(), containsString("Getting started with Angular"))); + // Click on the hamburger menu and then in the "Adding navigation" entry + // to go to "/start/start-routing" + onWebView() + .withNoTimeout() + .withElement(findElement(Locator.XPATH, "//button[contains(@class,'hamburger')]")) + .perform(webClick()); + Thread.sleep(2000); + onWebView() + .withNoTimeout() + .withElement(findElement(Locator.XPATH, + "//a[contains(@href,'start/start-routing') and contains(@class,'vertical-menu-item')]")) + .perform(webClick()); + Thread.sleep(4000); + // Check that the "Adding navigation" section is loaded + onWebView() + .withNoTimeout() + .withElement(findElement(Locator.TAG_NAME, "h1")) + .check(webMatches(getText(), containsString("Adding navigation"))); + Thread.sleep(2000); + } + + @Test + public void testReopenAppAndCheckLastUrl() throws InterruptedException { + // Click the 4th option in the Settings Dialog to + // reload the last URL visited + DataInteraction linearLayout = onData(anything()) + .inAdapterView(allOf(withId(R.id.lstServers), + TestUtils.childAtPosition( + withId(android.R.id.content), + 0))) + .atPosition(3); + Thread.sleep(2000); + linearLayout.perform(click()); + Thread.sleep(4000); + // Remembered "Adding navigation" section is loaded (last URL) + // instead of the "Getting started..." section (app URL) + onWebView() + .withNoTimeout() + .withElement(findElement(Locator.TAG_NAME, "h1")) + .check(webMatches(getText(), containsString("Adding navigation"))); + onWebView() + .withNoTimeout() + .withElement(findElement(Locator.TAG_NAME, "h1")) + .check(webMatches(getText(), not( + containsString("Getting started with Angular")))); + } +} diff --git a/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java index 1c8112e3..9ef4d6fc 100644 --- a/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java +++ b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java @@ -1,8 +1,5 @@ package org.medicmobile.webapp.mobile; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; import androidx.test.espresso.DataInteraction; import androidx.test.espresso.ViewInteraction; import androidx.test.espresso.web.webdriver.DriverAtoms; @@ -11,9 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import java.util.Locale; -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeMatcher; import org.junit.FixMethodOrder; import org.junit.Rule; import org.junit.Test; @@ -50,7 +44,7 @@ @FixMethodOrder(MethodSorters.NAME_ASCENDING) public class SettingsDialogActivityTest { - private static final String WEBAPP_URL = "CHT-Core URL"; + private static final String WEBAPP_URL = "CHT server URL"; private static final String SERVER_ONE = "https://medic.github.io/atp"; private static final String SERVER_TWO = "https://gamma-cht.dev.medicmobile.org"; private static final String SERVER_THREE = "https://gamma.dev.medicmobile.org"; @@ -76,7 +70,7 @@ public void serverSelectionScreenIsDisplayed() { textAppUrl.perform(replaceText("something"), closeSoftKeyboard()); onView(withId(R.id.btnSaveSettings)).perform(click()); - textAppUrl.check(matches(hasErrorText("must be a valid URL"))); + textAppUrl.check(matches(hasErrorText("Must be a valid URL"))); pressBack(); } @@ -85,7 +79,7 @@ public void serverSelectionScreenIsDisplayed() { public void testLoginScreen() throws Exception { DataInteraction linearLayout = onData(anything()) .inAdapterView(allOf(withId(R.id.lstServers), - childAtPosition( + TestUtils.childAtPosition( withId(android.R.id.content), 0))) .atPosition(2); @@ -139,23 +133,4 @@ private String getLanguage(String code) { Locale aLocale = new Locale(code); return aLocale.getDisplayName(); } - - private static Matcher childAtPosition( - final Matcher parentMatcher, final int position) { - - return new TypeSafeMatcher() { - @Override - public void describeTo(Description description) { - description.appendText("Child at position " + position + " in parent "); - parentMatcher.describeTo(description); - } - - @Override - public boolean matchesSafely(View view) { - ViewParent parent = view.getParent(); - return parent instanceof ViewGroup && parentMatcher.matches(parent) - && view.equals(((ViewGroup) parent).getChildAt(position)); - } - }; - } } diff --git a/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/TestUtils.java b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/TestUtils.java new file mode 100644 index 00000000..9550ea36 --- /dev/null +++ b/src/androidTestUnbrandedWebviewDebug/java/org/medicmobile/webapp/mobile/TestUtils.java @@ -0,0 +1,29 @@ +package org.medicmobile.webapp.mobile; + +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +public abstract class TestUtils { + + public static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/src/chis_ne/AndroidManifest.xml b/src/chis_ne/AndroidManifest.xml new file mode 100644 index 00000000..def9a408 --- /dev/null +++ b/src/chis_ne/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/chis_ne/ic_launcher-playstore.png b/src/chis_ne/ic_launcher-playstore.png new file mode 100644 index 00000000..ad9fde4e Binary files /dev/null and b/src/chis_ne/ic_launcher-playstore.png differ diff --git a/src/chis_ne/res/mipmap-anydpi-v26/ic_launcher.xml b/src/chis_ne/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..80b730f3 --- /dev/null +++ b/src/chis_ne/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/chis_ne/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/chis_ne/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..80b730f3 --- /dev/null +++ b/src/chis_ne/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/chis_ne/res/mipmap-hdpi/ic_launcher.png b/src/chis_ne/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..7a330e35 Binary files /dev/null and b/src/chis_ne/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/chis_ne/res/mipmap-hdpi/ic_launcher_foreground.png b/src/chis_ne/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2be60755 Binary files /dev/null and b/src/chis_ne/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src/chis_ne/res/mipmap-hdpi/ic_launcher_round.png b/src/chis_ne/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..28c4d5ce Binary files /dev/null and b/src/chis_ne/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src/chis_ne/res/mipmap-mdpi/ic_launcher.png b/src/chis_ne/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..2a4e2554 Binary files /dev/null and b/src/chis_ne/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/chis_ne/res/mipmap-mdpi/ic_launcher_foreground.png b/src/chis_ne/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..9331fe4c Binary files /dev/null and b/src/chis_ne/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src/chis_ne/res/mipmap-mdpi/ic_launcher_round.png b/src/chis_ne/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..d90540c5 Binary files /dev/null and b/src/chis_ne/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src/chis_ne/res/mipmap-xhdpi/ic_launcher.png b/src/chis_ne/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..621c0aef Binary files /dev/null and b/src/chis_ne/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/chis_ne/res/mipmap-xhdpi/ic_launcher_foreground.png b/src/chis_ne/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..bf821961 Binary files /dev/null and b/src/chis_ne/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src/chis_ne/res/mipmap-xhdpi/ic_launcher_round.png b/src/chis_ne/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..5834ee97 Binary files /dev/null and b/src/chis_ne/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src/chis_ne/res/mipmap-xxhdpi/ic_launcher.png b/src/chis_ne/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..24c43720 Binary files /dev/null and b/src/chis_ne/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/chis_ne/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src/chis_ne/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..d80ab34e Binary files /dev/null and b/src/chis_ne/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src/chis_ne/res/mipmap-xxhdpi/ic_launcher_round.png b/src/chis_ne/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..3c29b433 Binary files /dev/null and b/src/chis_ne/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher.png b/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..2d6ec1be Binary files /dev/null and b/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..d481613c Binary files /dev/null and b/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher_round.png b/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b17ea21c Binary files /dev/null and b/src/chis_ne/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src/chis_ne/res/values/ic_launcher_background.xml b/src/chis_ne/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..f42ada65 --- /dev/null +++ b/src/chis_ne/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/src/chis_ne/res/values/strings.xml b/src/chis_ne/res/values/strings.xml new file mode 100644 index 00000000..347e3349 --- /dev/null +++ b/src/chis_ne/res/values/strings.xml @@ -0,0 +1,5 @@ + + + CHIS + chis.dohs.gov.np + diff --git a/src/cht_rci/AndroidManifest.xml b/src/cht_rci/AndroidManifest.xml new file mode 100644 index 00000000..def9a408 --- /dev/null +++ b/src/cht_rci/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/cht_rci/res/mipmap-hdpi/ic_launcher.png b/src/cht_rci/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..baaa9569 Binary files /dev/null and b/src/cht_rci/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/cht_rci/res/mipmap-mdpi/ic_launcher.png b/src/cht_rci/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..c39d97ad Binary files /dev/null and b/src/cht_rci/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/cht_rci/res/mipmap-xhdpi/ic_launcher.png b/src/cht_rci/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..8454b063 Binary files /dev/null and b/src/cht_rci/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/cht_rci/res/mipmap-xxhdpi/ic_launcher.png b/src/cht_rci/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..b5638b41 Binary files /dev/null and b/src/cht_rci/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/cht_rci/res/mipmap-xxxhdpi/ic_launcher.png b/src/cht_rci/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..a5b3345d Binary files /dev/null and b/src/cht_rci/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/cht_rci/res/values/strings.xml b/src/cht_rci/res/values/strings.xml new file mode 100644 index 00000000..74b91f5a --- /dev/null +++ b/src/cht_rci/res/values/strings.xml @@ -0,0 +1,5 @@ + + + CHT-RCI + muso-cdi.app.medicmobile.org + diff --git a/src/cht_rci_test/AndroidManifest.xml b/src/cht_rci_test/AndroidManifest.xml new file mode 100644 index 00000000..def9a408 --- /dev/null +++ b/src/cht_rci_test/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/cht_rci_test/res/mipmap-hdpi/ic_launcher.png b/src/cht_rci_test/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..baaa9569 Binary files /dev/null and b/src/cht_rci_test/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/cht_rci_test/res/mipmap-mdpi/ic_launcher.png b/src/cht_rci_test/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..c39d97ad Binary files /dev/null and b/src/cht_rci_test/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/cht_rci_test/res/mipmap-xhdpi/ic_launcher.png b/src/cht_rci_test/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..8454b063 Binary files /dev/null and b/src/cht_rci_test/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/cht_rci_test/res/mipmap-xxhdpi/ic_launcher.png b/src/cht_rci_test/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..b5638b41 Binary files /dev/null and b/src/cht_rci_test/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/cht_rci_test/res/mipmap-xxxhdpi/ic_launcher.png b/src/cht_rci_test/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..a5b3345d Binary files /dev/null and b/src/cht_rci_test/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/cht_rci_test/res/values/strings.xml b/src/cht_rci_test/res/values/strings.xml new file mode 100644 index 00000000..4a504e9f --- /dev/null +++ b/src/cht_rci_test/res/values/strings.xml @@ -0,0 +1,5 @@ + + + CHT-RCI-TEST + muso-cdi.dev.medicmobile.org + diff --git a/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher.xml b/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher.xml index 4ae7d123..cb73a957 100644 --- a/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher_round.xml index 4ae7d123..cb73a957 100644 --- a/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/src/covid_moh_mali/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher.xml b/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher.xml index c4a603d4..d372a4fc 100644 --- a/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher_round.xml index c4a603d4..d372a4fc 100644 --- a/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/src/icm_ph_chc/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/src/itech_aurum/AndroidManifest.xml b/src/itech_aurum/AndroidManifest.xml index d146258e..83985ddd 100644 --- a/src/itech_aurum/AndroidManifest.xml +++ b/src/itech_aurum/AndroidManifest.xml @@ -4,5 +4,4 @@ package="org.medicmobile.webapp.mobile"> - - \ No newline at end of file + diff --git a/src/itech_aurum/res/mipmap-anydpi-v26/ic_launcher.xml b/src/itech_aurum/res/mipmap-anydpi-v26/ic_launcher.xml index fe66b0bc..80b730f3 100644 --- a/src/itech_aurum/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/src/itech_aurum/res/mipmap-anydpi-v26/ic_launcher.xml @@ -3,4 +3,3 @@ - \ No newline at end of file diff --git a/src/itech_aurum/res/values/ic_launcher_background.xml b/src/itech_aurum/res/values/ic_launcher_background.xml index c5d5899f..f42ada65 100644 --- a/src/itech_aurum/res/values/ic_launcher_background.xml +++ b/src/itech_aurum/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ #FFFFFF - \ No newline at end of file + diff --git a/src/itech_malawi/AndroidManifest.xml b/src/itech_malawi/AndroidManifest.xml index d146258e..0cfc77d4 100644 --- a/src/itech_malawi/AndroidManifest.xml +++ b/src/itech_malawi/AndroidManifest.xml @@ -2,7 +2,5 @@ - - - \ No newline at end of file + diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 1dddf15f..232efd8e 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -59,15 +59,16 @@ + - - - diff --git a/src/main/assets/xwalk-command-line b/src/main/assets/xwalk-command-line deleted file mode 100644 index 3f9aebc0..00000000 --- a/src/main/assets/xwalk-command-line +++ /dev/null @@ -1 +0,0 @@ -xwalk --unlimited-storage diff --git a/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java b/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java index b6bca336..551ff41b 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java +++ b/src/main/java/org/medicmobile/webapp/mobile/AppUrlVerifier.java @@ -4,7 +4,6 @@ import java.net.MalformedURLException; import org.json.JSONException; import org.json.JSONObject; - import static org.medicmobile.webapp.mobile.BuildConfig.DISABLE_APP_URL_VALIDATION; import static org.medicmobile.webapp.mobile.MedicLog.trace; import static org.medicmobile.webapp.mobile.R.string.errAppUrl_apiNotReady; @@ -14,13 +13,28 @@ import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; public class AppUrlVerifier { + + private final SimpleJsonClient2 jsonClient; + + AppUrlVerifier(SimpleJsonClient2 jsonClient) { + this.jsonClient = jsonClient; + } + + public AppUrlVerifier() { + this(new SimpleJsonClient2()); + } + + /** + * Verify the string passed is a valid CHT-Core URL. + */ public AppUrlVerification verify(String appUrl) { + appUrl = clean(appUrl); if(DISABLE_APP_URL_VALIDATION) { return AppUrlVerification.ok(appUrl); } try { - JSONObject json = new SimpleJsonClient2().get(appUrl + "/setup/poll"); + JSONObject json = jsonClient.get(appUrl + "/setup/poll"); if(!json.getString("handler").equals("medic-api")) return AppUrlVerification.failure(appUrl, errAppUrl_appNotFound); @@ -41,6 +55,18 @@ public AppUrlVerification verify(String appUrl) { errAppUrl_serverNotFound); } } + + /** + * 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) { + appUrl = appUrl.trim(); + if (appUrl.endsWith("/")) { + return appUrl.substring(0, appUrl.length()-1); + } + return appUrl; + } } @SuppressWarnings("PMD.ShortMethodName") diff --git a/src/main/java/org/medicmobile/webapp/mobile/ChtExternalAppHandler.java b/src/main/java/org/medicmobile/webapp/mobile/ChtExternalAppHandler.java index 793ad68a..5cad0911 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/ChtExternalAppHandler.java +++ b/src/main/java/org/medicmobile/webapp/mobile/ChtExternalAppHandler.java @@ -3,8 +3,7 @@ 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 org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.ACCESS_STORAGE_PERMISSION_REQUEST_CODE; -import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.RequestCode; import static org.medicmobile.webapp.mobile.JavascriptUtils.safeFormat; import static org.medicmobile.webapp.mobile.MedicLog.error; import static org.medicmobile.webapp.mobile.MedicLog.trace; @@ -12,8 +11,9 @@ import android.app.Activity; import android.content.Intent; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; +import androidx.core.content.ContextCompat; +import androidx.core.app.ActivityCompat; + import org.json.JSONObject; @@ -58,7 +58,11 @@ void startIntent(ChtExternalApp chtExternalApp) { if (ContextCompat.checkSelfPermission(this.context, READ_EXTERNAL_STORAGE) != PERMISSION_GRANTED) { trace(this, "ChtExternalAppHandler :: Requesting storage permissions to process image files taken from external apps"); this.lastIntent = intent; // Saving intent to start it when permission is granted. - ActivityCompat.requestPermissions(this.context, PERMISSIONS_STORAGE, ACCESS_STORAGE_PERMISSION_REQUEST_CODE); + ActivityCompat.requestPermissions( + this.context, + PERMISSIONS_STORAGE, + RequestCode.ACCESS_STORAGE_PERMISSION.getCode() + ); return; } @@ -79,7 +83,7 @@ void resumeActivity() { private void startActivity(Intent intent) { try { trace(this, "ChtExternalAppHandler :: Starting activity %s %s", intent, intent.getExtras()); - this.context.startActivityForResult(intent, CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE); + this.context.startActivityForResult(intent, RequestCode.CHT_EXTERNAL_APP_ACTIVITY.getCode()); } catch (Exception exception) { error(exception, "ChtExternalAppHandler :: Error when starting the activity %s %s", intent, intent.getExtras()); diff --git a/src/webview/java/org/medicmobile/webapp/mobile/ConnectionUtils.java b/src/main/java/org/medicmobile/webapp/mobile/ConnectionUtils.java similarity index 63% rename from src/webview/java/org/medicmobile/webapp/mobile/ConnectionUtils.java rename to src/main/java/org/medicmobile/webapp/mobile/ConnectionUtils.java index af31b2ee..9337fb2b 100644 --- a/src/webview/java/org/medicmobile/webapp/mobile/ConnectionUtils.java +++ b/src/main/java/org/medicmobile/webapp/mobile/ConnectionUtils.java @@ -1,7 +1,5 @@ package org.medicmobile.webapp.mobile; -import android.webkit.WebResourceError; - import static android.webkit.WebViewClient.ERROR_CONNECT; import static android.webkit.WebViewClient.ERROR_HOST_LOOKUP; import static android.webkit.WebViewClient.ERROR_PROXY_AUTHENTICATION; @@ -12,8 +10,8 @@ */ public abstract class ConnectionUtils { - public static boolean isConnectionError(WebResourceError error) { - switch (error.getErrorCode()) { + public static boolean isConnectionError(int errorCode) { + switch (errorCode) { case ERROR_HOST_LOOKUP: case ERROR_PROXY_AUTHENTICATION: case ERROR_CONNECT: @@ -23,7 +21,7 @@ public static boolean isConnectionError(WebResourceError error) { return false; } - public static String connectionErrorToString(WebResourceError error) { - return String.format("%s [%s]", error.getDescription(), error.getErrorCode()); + public static String connectionErrorToString(int errorCode, String errorDescription) { + return String.format("%s [%s]", errorDescription, errorCode); } } diff --git a/src/webview/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java similarity index 55% rename from src/webview/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java rename to src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java index 72cc7020..d8557c03 100644 --- a/src/webview/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java @@ -1,77 +1,61 @@ package org.medicmobile.webapp.mobile; -import android.Manifest; +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.BuildConfig.DEBUG; +import static org.medicmobile.webapp.mobile.MedicLog.error; +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 org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; +import static org.medicmobile.webapp.mobile.Utils.createUseragentFrom; +import static org.medicmobile.webapp.mobile.Utils.isValidNavigationUrl; + import android.annotation.SuppressLint; +import android.app.ActivityManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.app.ActivityManager; import android.content.IntentFilter; -import android.graphics.Bitmap; -import android.net.Uri; import android.net.ConnectivityManager; +import android.net.Uri; import android.os.Bundle; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.Window; import android.webkit.ConsoleMessage; -import android.webkit.CookieManager; import android.webkit.GeolocationPermissions; import android.webkit.ValueCallback; import android.webkit.WebChromeClient; -import android.webkit.WebResourceError; -import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebView; -import android.webkit.WebViewClient; import android.widget.Toast; -import java.util.Arrays; - -import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; -import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; -import static org.medicmobile.webapp.mobile.BuildConfig.DISABLE_APP_URL_VALIDATION; -import static org.medicmobile.webapp.mobile.MedicLog.error; -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 org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; -import static org.medicmobile.webapp.mobile.ConnectionUtils.connectionErrorToString; -import static org.medicmobile.webapp.mobile.ConnectionUtils.isConnectionError; -import static org.medicmobile.webapp.mobile.Utils.createUseragentFrom; -import static org.medicmobile.webapp.mobile.Utils.isUrlRelated; -import static org.medicmobile.webapp.mobile.Utils.restartApp; +import java.util.Arrays; +import java.util.Optional; @SuppressWarnings({ "PMD.GodClass", "PMD.TooManyMethods" }) public class EmbeddedBrowserActivity extends LockableActivity { - /** - * Any activity result with all 3 low bits set is _not_ a simprints result. - * - * The following block of bit-shifted integers are intended for use in the subsystem seen - * in the onActivityResult below. These integers respect the reserved block of integers - * which are used by simprints. Simprint intents are started in the webapp where a matching - * bitmask is used to respect the scheme on that side of things. - * */ - private static final int NON_SIMPRINTS_FLAGS = 0x7; - static final int GRAB_PHOTO_ACTIVITY_REQUEST_CODE = (0 << 3) | NON_SIMPRINTS_FLAGS; - static final int GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE = (1 << 3) | NON_SIMPRINTS_FLAGS; - static final int DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE = (2 << 3) | NON_SIMPRINTS_FLAGS; - static final int ACCESS_STORAGE_PERMISSION_REQUEST_CODE = (3 << 3) | NON_SIMPRINTS_FLAGS; - static final int CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE = (4 << 3) | NON_SIMPRINTS_FLAGS; - - // Arbitrarily selected value - private static final int ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE = 7038678; - - private static final String[] LOCATION_PERMISSIONS = { Manifest.permission.ACCESS_FINE_LOCATION }; + + private WebView container; + private SettingsStore settings; + private String appUrl; + private MrdtSupport mrdt; + private PhotoGrabber photoGrabber; + private SmsSender smsSender; + private ChtExternalAppHandler chtExternalAppHandler; + private boolean isMigrationRunning = false; + + static final String[] LOCATION_PERMISSIONS = { ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION }; private static final ValueCallback IGNORE_RESULT = new ValueCallback() { public void onReceiveValue(String result) { /* ignore */ } }; - private final ValueCallback backButtonHandler = new ValueCallback() { public void onReceiveValue(String result) { if(!"true".equals(result)) { @@ -80,16 +64,6 @@ public void onReceiveValue(String result) { } }; - private WebView container; - private SettingsStore settings; - private String appUrl; - private SimprintsSupport simprints; - private MrdtSupport mrdt; - private PhotoGrabber photoGrabber; - private SmsSender smsSender; - private ChtExternalAppHandler chtExternalAppHandler; - - private boolean isMigrationRunning = false; //> ACTIVITY LIFECYCLE METHODS @Override public void onCreate(Bundle savedInstanceState) { @@ -97,7 +71,6 @@ public void onReceiveValue(String result) { trace(this, "Starting webview..."); - this.simprints = new SimprintsSupport(this); this.photoGrabber = new PhotoGrabber(this); this.mrdt = new MrdtSupport(this); this.chtExternalAppHandler = new ChtExternalAppHandler(this); @@ -115,8 +88,7 @@ public void onReceiveValue(String result) { // Add an alarming red border if using configurable (i.e. dev) // app with a medic production server. - if(settings.allowsConfiguration() && - appUrl.contains("app.medicmobile.org")) { + if (settings.allowsConfiguration() && appUrl != null && appUrl.contains("app.medicmobile.org")) { View webviewContainer = findViewById(R.id.lytWebView); webviewContainer.setPadding(10, 10, 10, 10); webviewContainer.setBackgroundColor(R.drawable.warning_background); @@ -124,7 +96,7 @@ public void onReceiveValue(String result) { container = findViewById(R.id.wbvMain); - configureUseragent(); + configureUserAgent(); setUpUiClient(container); enableRemoteChromeDebugging(); @@ -137,11 +109,16 @@ public void onReceiveValue(String result) { Uri appLinkData = appLinkIntent.getData(); browseTo(appLinkData); - if(settings.allowsConfiguration()) { + if (settings.allowsConfiguration()) { toast(redactUrl(appUrl)); } registerRetryConnectionBroadcastReceiver(); + + String recentNavigation = settings.getLastUrl(); + if (isValidNavigationUrl(appUrl, recentNavigation)) { + container.loadUrl(recentNavigation); + } } @Override @@ -163,6 +140,19 @@ protected void onStart() { super.onStart(); } + @Override + protected void onStop() { + super.onStop(); + String recentNavigation = container.getUrl(); + if (isValidNavigationUrl(appUrl, recentNavigation)) { + try { + settings.setLastUrl(recentNavigation); + } catch (SettingsException e) { + error(e, "Error recording last URL loaded"); + } + } + } + @Override public boolean onCreateOptionsMenu(Menu menu) { if(settings.allowsConfiguration()) { getMenuInflater().inflate(R.menu.unbranded_web_menu, menu); @@ -203,62 +193,75 @@ protected void onStart() { backButtonHandler); } - @Override protected void onActivityResult(int requestCode, int resultCode, Intent i) { + @Override protected void onActivityResult(int requestCd, int resultCode, Intent intent) { + Optional requestCodeOpt = RequestCode.valueOf(requestCd); + + if (!requestCodeOpt.isPresent()) { + trace(this, "onActivityResult() :: no handling for requestCode=%s", requestCd); + return; + } + + RequestCode requestCode = requestCodeOpt.get(); + try { - trace(this, "onActivityResult() :: requestCode=%s, resultCode=%s", - requestCodeToString(requestCode), resultCode); - if((requestCode & NON_SIMPRINTS_FLAGS) == NON_SIMPRINTS_FLAGS) { - switch(requestCode) { - case GRAB_PHOTO_ACTIVITY_REQUEST_CODE: - photoGrabber.process(requestCode, resultCode, i); - return; - case GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE: - String js = mrdt.process(requestCode, resultCode, i); - trace(this, "Execing JS: %s", js); - evaluateJavascript(js); - return; - case DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE: - // User accepted or denied to allow the app to access - // location data in RequestPermissionActivity - if (resultCode == RESULT_OK) { // user accepted - // Request to Android location data access - ActivityCompat.requestPermissions( - this, - LOCATION_PERMISSIONS, - ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE); - } else if (resultCode == RESULT_CANCELED) { // user rejected - try { - this.locationRequestResolved(); - settings.setUserDeniedGeolocation(); - } catch (SettingsException e) { - error(e, "Error recording negative to access location"); - } - } - return; - case CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE: - processChtExternalAppResult(resultCode, i); - return; - default: - trace(this, "onActivityResult() :: no handling for requestCode=%s", - requestCodeToString(requestCode)); - } - } else { - String js = simprints.process(requestCode, i); - trace(this, "Execing JS: %s", js); - evaluateJavascript(js); + trace(this, "onActivityResult() :: requestCode=%s, resultCode=%s", requestCode.name(), resultCode); + + switch (requestCode) { + case GRAB_PHOTO_ACTIVITY: + photoGrabber.process(requestCode, resultCode, intent); + return; + case GRAB_MRDT_PHOTO_ACTIVITY: + processMrdtResult(requestCode, resultCode, intent); + return; + case DISCLOSURE_LOCATION_ACTIVITY: + processLocationPermissionResult(resultCode); + return; + case CHT_EXTERNAL_APP_ACTIVITY: + processChtExternalAppResult(resultCode, intent); + return; + default: + trace(this, "onActivityResult() :: no handling for requestCode=%s", requestCode.name()); } - } catch(Exception ex) { - String action = i == null ? null : i.getAction(); + } catch (Exception ex) { + String action = intent == null ? null : intent.getAction(); warn(ex, "Problem handling intent %s (%s) with requestCode=%s & resultCode=%s", - i, action, requestCodeToString(requestCode), resultCode); + intent, action, requestCode.name(), resultCode); } } -//> ACCESSORS - SimprintsSupport getSimprintsSupport() { - return this.simprints; + @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; } @@ -293,50 +296,85 @@ public void errorToJsConsole(String message, Object... extras) { evaluateJavascript("console.error('" + escaped + "');"); } -//> PRIVATE HELPERS - private String requestCodeToString(int requestCode) { - if (requestCode == ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE) { - return "ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE"; + public boolean isMigrationRunning() { + return isMigrationRunning; + } + + public void setMigrationRunning(boolean migrationRunning) { + isMigrationRunning = migrationRunning; + } + + public boolean getLocationPermissions() { + 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, "getLocationPermissions() :: already granted"); + return true; } - if (requestCode == DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE) { - return "DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE"; + if (settings.hasUserDeniedGeolocation()) { + trace(this, "getLocationPermissions() :: user has previously denied to share location"); + locationRequestResolved(); + return false; } - return String.valueOf(requestCode); + trace(this, "getLocationPermissions() :: location not granted before, requesting access..."); + Intent intent = new Intent(this, RequestPermissionActivity.class); + startActivityForResult(intent, RequestCode.DISCLOSURE_LOCATION_ACTIVITY.getCode()); + return false; + } + + public void locationRequestResolved() { + evaluateJavascript("window.CHTCore.AndroidApi.v1.locationPermissionRequestResolved();"); } +//> PRIVATE HELPERS 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 configureUseragent() { - String current = WebSettings.getDefaultUserAgent(this); - container.getSettings().setUserAgentString(createUseragentFrom(current)); + private void processMrdtResult(RequestCode requestCode, int resultCode, Intent intent) { + String js = mrdt.process(requestCode, intent); + trace(this, "Executing JavaScript: %s", js); + evaluateJavascript(js); } - private void openSettings() { - startActivity(new Intent(this, - SettingsDialogActivity.class)); - finish(); + 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 String getRootUrl() { - return appUrl + (DISABLE_APP_URL_VALIDATION ? - "" : "/medic/_design/medic/_rewrite/"); + private void processGeolocationDeniedStatus() { + try { + settings.setUserDeniedGeolocation(); + locationRequestResolved(); + } catch (SettingsException e) { + error(e, "LocationPermissionRequest :: Error recording negative to access location"); + } } - private String getUrlToLoad(Uri url) { - if (url != null) { - return url.toString(); - } - return getRootUrl(); + private void configureUserAgent() { + String current = WebSettings.getDefaultUserAgent(this); + container.getSettings().setUserAgentString(createUseragentFrom(current)); + } + + private void openSettings() { + startActivity(new Intent(this, SettingsDialogActivity.class)); + finish(); } private void browseTo(Uri url) { - String urlToLoad = getUrlToLoad(url); + String urlToLoad = this.settings.getUrlToLoad(url); trace(this, "Pointing browser to: %s", redactUrl(urlToLoad)); container.loadUrl(urlToLoad, null); } @@ -381,48 +419,6 @@ private void setUpUiClient(WebView container) { }); } - public boolean getLocationPermissions() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PERMISSION_GRANTED) { - trace(this, "getLocationPermissions() :: already granted"); - return true; - } - if (settings.hasUserDeniedGeolocation()) { - trace(this, "getLocationPermissions() :: user has previously denied to share location"); - this.locationRequestResolved(); - return false; - } - trace(this, "getLocationPermissions() :: location not granted before, requesting access..."); - startActivityForResult( - new Intent(this, RequestPermissionActivity.class), - DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE); - return false; - } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - boolean granted = grantResults.length > 0 && grantResults[0] == PERMISSION_GRANTED; - - if (requestCode == ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE) { - locationRequestResolved(); - return; - } - - if (requestCode == ACCESS_STORAGE_PERMISSION_REQUEST_CODE) { - if (granted) { - this.chtExternalAppHandler.resumeActivity(); - return; - } - trace(this, "ChtExternalAppHandler :: User rejected permission."); - return; - } - } - - public void locationRequestResolved() { - evaluateJavascript( - "angular.element(document.body).injector().get('AndroidApi').v1.locationPermissionRequestResolved();"); - } - @SuppressLint("SetJavaScriptEnabled") private void enableJavascript(WebView container) { container.getSettings().setJavaScriptEnabled(true); @@ -444,90 +440,13 @@ private void enableStorage(WebView container) { } private void enableUrlHandlers(WebView container) { - - container.setWebViewClient(new WebViewClient() { - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - Uri uri = request.getUrl(); - if (isUrlRelated(appUrl, uri)) { - // load all related URLs in the webview - return false; - } - - // let Android decide what to do with unrelated URLs - // unrelated URLs include `tel:` and `sms:` uri schemes - Intent i = new Intent(Intent.ACTION_VIEW, uri); - view.getContext().startActivity(i); - return true; - } - - @Override - public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { - String failingUrl = request.getUrl().toString(); - log(this, "onReceivedLoadError() :: url: %s, error code: %s, description: %s", - failingUrl, error.getErrorCode(), error.getDescription()); - if (!getRootUrl().equals(failingUrl)) { - super.onReceivedError(view, request, error); - } else if (isConnectionError(error)) { - String connErrorInfo = connectionErrorToString(error); - Intent intent = new Intent(view.getContext(), ConnectionErrorActivity.class); - intent.putExtra("connErrorInfo", connErrorInfo); - if (isMigrationRunning) { - // Activity is not closable if the migration is running - intent - .putExtra("isClosable", false) - .putExtra("backPressedMessage", getString(R.string.waitMigration)); - } - startActivity(intent); - } else { - evaluateJavascript(String.format( - "var body = document.evaluate('/html/body', document);" + - "body = body.iterateNext();" + - "if(body) {" + - " var content = document.createElement('div');" + - " content.innerHTML = '" + - "

Error loading page

" + - "

[%s] %s

" + - "" + - "';" + - " body.appendChild(content);" + - "}", error.getErrorCode(), error.getDescription()), false); - } - } - - // Check how the migration process is going if it was started. - // Because most of the cases after the XWalk -> Webview migration process ends - // the cookies are not available for unknowns reasons, making the webapp to - // redirect the user to the login page instead of the main page. - // If these conditions are met: migration running + /login page + no cookies, - // the app is restarted to refresh the Webview and prevent the user to - // login again. - @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { - trace(this, "onPageStarted() :: url: %s, isMigrationRunning: %s", url, isMigrationRunning); - if (isMigrationRunning && url.contains("/login")) { - isMigrationRunning = false; - CookieManager cookieManager = CookieManager.getInstance(); - String cookie = cookieManager.getCookie(appUrl); - if (cookie == null) { - log(this, "onPageStarted() :: Migration process in progress, and " + - "cookies were not loaded, restarting ..."); - restartApp(view.getContext()); - } - trace(this, "onPageStarted() :: Cookies loaded, skipping restart"); - } - } - - @Override public void onPageFinished(WebView view, String url) { - trace(this, "onPageFinished() :: url: %s", url); - // Broadcast the event so if the connection error - // activity is listening it will close - sendBroadcast(new Intent("onPageFinished")); - } - }); + container.setWebViewClient(new UrlHandler(this, settings)); } private void toast(String message) { - Toast.makeText(container.getContext(), message, Toast.LENGTH_LONG).show(); + if (message != null) { + Toast.makeText(container.getContext(), message, Toast.LENGTH_LONG).show(); + } } private void registerRetryConnectionBroadcastReceiver() { @@ -543,4 +462,32 @@ private void registerRetryConnectionBroadcastReceiver() { }; registerReceiver(broadcastReceiver, new IntentFilter("retryConnection")); } + +//> ENUMS + 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), + GRAB_PHOTO_ACTIVITY(105); + + private final int requestCode; + + RequestCode(int requestCode) { + this.requestCode = requestCode; + } + + public static Optional valueOf(int code) { + return Arrays + .stream(RequestCode.values()) + .filter(e -> e.getCode() == code) + .findFirst(); + } + + public int getCode() { + return requestCode; + } + } + } diff --git a/src/main/java/org/medicmobile/webapp/mobile/LockScreen.java b/src/main/java/org/medicmobile/webapp/mobile/LockScreen.java index 2cf19d08..0426a85d 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/LockScreen.java +++ b/src/main/java/org/medicmobile/webapp/mobile/LockScreen.java @@ -61,8 +61,9 @@ enum Task { final ViewGroup group = (ViewGroup) findViewById(R.id.divButtons); int i = group.getChildCount(); OnClickListener buttonListener = new OnClickListener() { - @Override public void onClick(View v) { - String newText = txtPin.getText() + ((Button) v).getText().toString(); + @Override public void onClick(View view) { + assert view instanceof Button; + String newText = txtPin.getText() + ((Button) view).getText().toString(); txtPin.setText(newText); txtPin.setSelection(newText.length()); } diff --git a/src/webview/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java similarity index 89% rename from src/webview/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java rename to src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java index 5994b1d6..253883ab 100644 --- a/src/webview/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -18,10 +18,13 @@ import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStreamReader; import java.io.PrintWriter; +import java.io.Reader; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -38,11 +41,12 @@ import static java.util.Locale.UK; import static org.medicmobile.webapp.mobile.MedicLog.log; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + public class MedicAndroidJavascript { private static final String DATE_FORMAT = "yyyy-MM-dd"; private final EmbeddedBrowserActivity parent; - private final SimprintsSupport simprints; private final MrdtSupport mrdt; private final SmsSender smsSender; private final ChtExternalAppHandler chtExternalAppHandler; @@ -53,7 +57,6 @@ public class MedicAndroidJavascript { public MedicAndroidJavascript(EmbeddedBrowserActivity parent) { this.parent = parent; - this.simprints = parent.getSimprintsSupport(); this.mrdt = parent.getMrdtSupport(); this.smsSender = parent.getSmsSender(); this.chtExternalAppHandler = parent.getChtExternalAppLauncherActivity(); @@ -162,38 +165,6 @@ public void mrdt_verify() { } } - /** - * @return {@code true} iff an app is available to handle supported simprints {@code Intent}s - */ - @android.webkit.JavascriptInterface - public boolean simprints_available() { - try { - return simprints.isAppInstalled(); - } catch(Exception ex) { - logException(ex); - return false; - } - } - - @android.webkit.JavascriptInterface - public void simprints_ident(int targetInputId) { - try { - simprints.startIdent(targetInputId); - } catch(Exception ex) { - logException(ex); - } - } - - @android.webkit.JavascriptInterface - public void simprints_reg(int targetInputId) { - try { - simprints.startReg(targetInputId); - } catch(Exception ex) { - logException(ex); - } - } - - @android.webkit.JavascriptInterface public boolean sms_available() { return smsSender != null; @@ -240,6 +211,7 @@ public void launchExternalApp(String action, String category, String type, Strin } @SuppressLint("ObsoleteSdkInt") + @SuppressFBWarnings("REC_CATCH_EXCEPTION") @android.webkit.JavascriptInterface public String getDeviceInfo() { try { @@ -325,7 +297,8 @@ public String getDeviceInfo() { .put("ram", ramObject) .put("network", networkObject) .toString(); - } catch(Exception ex) { + } catch (Exception ex) { + logException(ex); return jsonError("Problem fetching device info: ", ex); } } @@ -363,28 +336,31 @@ public void onDateSet(DatePicker view, int year, int month, int day) { } private static HashMap getCPUInfo() throws IOException { - BufferedReader bufferedReader = new BufferedReader(new FileReader("/proc/cpuinfo")); - String line; - HashMap output = new HashMap(); - while ((line = bufferedReader.readLine()) != null) { - String[] data = line.split(":"); - if (data.length > 1) { - String key = data[0].trim(); - if (key.equals("model name")) { - output.put(key, data[1].trim()); - break; + try( + Reader fileReader = new InputStreamReader(new FileInputStream("/proc/cpuinfo"), StandardCharsets.UTF_8); + BufferedReader bufferedReader = new BufferedReader(fileReader); + ) { + String line; + HashMap output = new HashMap(); + while ((line = bufferedReader.readLine()) != null) { + String[] data = line.split(":"); + if (data.length > 1) { + String key = data[0].trim(); + if (key.equals("model name")) { + output.put(key, data[1].trim()); + break; + } } } - } - bufferedReader.close(); - int cores = Runtime.getRuntime().availableProcessors(); - output.put("cores", cores); + int cores = Runtime.getRuntime().availableProcessors(); + output.put("cores", cores); - String arch = System.getProperty("os.arch"); - output.put("arch", arch); + String arch = System.getProperty("os.arch"); + output.put("arch", arch); - return output; + return output; + } } private void logException(Exception ex) { diff --git a/src/main/java/org/medicmobile/webapp/mobile/MrdtSupport.java b/src/main/java/org/medicmobile/webapp/mobile/MrdtSupport.java index b3553ec8..bc603a49 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MrdtSupport.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MrdtSupport.java @@ -6,7 +6,7 @@ //import org.json.JSONException; -import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.RequestCode; import static org.medicmobile.webapp.mobile.JavascriptUtils.safeFormat; import static org.medicmobile.webapp.mobile.MedicLog.trace; import static org.medicmobile.webapp.mobile.MedicLog.warn; @@ -26,14 +26,14 @@ boolean isAppInstalled() { } void startVerify() { - ctx.startActivityForResult(verifyIntent(), GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE); + ctx.startActivityForResult(verifyIntent(), RequestCode.GRAB_MRDT_PHOTO_ACTIVITY.getCode()); } - String process(int requestCode, int resultCode, Intent i) { - trace(this, "process() :: requestCode=%s", requestCode); + String process(RequestCode requestCode, Intent i) { + trace(this, "process() :: requestCode=%s", requestCode.name()); - switch(requestCode) { - case GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE: { + switch (requestCode) { + case GRAB_MRDT_PHOTO_ACTIVITY: { try { byte[] data = i.getByteArrayExtra("data"); String base64data = Base64.encodeToString(data, Base64.NO_WRAP); @@ -51,7 +51,7 @@ String process(int requestCode, int resultCode, Intent i) { } } - default: throw new RuntimeException("Bad request type: " + requestCode); + default: throw new RuntimeException("Bad request type: " + requestCode.name()); } } diff --git a/src/webview/java/org/medicmobile/webapp/mobile/PhotoGrabber.java b/src/main/java/org/medicmobile/webapp/mobile/PhotoGrabber.java similarity index 96% rename from src/webview/java/org/medicmobile/webapp/mobile/PhotoGrabber.java rename to src/main/java/org/medicmobile/webapp/mobile/PhotoGrabber.java index 34d4b893..72cebd8b 100644 --- a/src/webview/java/org/medicmobile/webapp/mobile/PhotoGrabber.java +++ b/src/main/java/org/medicmobile/webapp/mobile/PhotoGrabber.java @@ -17,7 +17,7 @@ import static android.provider.MediaStore.ACTION_IMAGE_CAPTURE; import static com.mvc.imagepicker.ImagePicker.getImageFromResult; import static com.mvc.imagepicker.ImagePicker.getPickImageIntent; -import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.GRAB_PHOTO_ACTIVITY_REQUEST_CODE; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.RequestCode; import static org.medicmobile.webapp.mobile.MedicLog.log; import static org.medicmobile.webapp.mobile.MedicLog.trace; import static org.medicmobile.webapp.mobile.MedicLog.warn; @@ -64,9 +64,9 @@ void chooser(ValueCallback callback, boolean capture) { else pickImage(); } - void process(int requestCode, int resultCode, Intent i) { + void process(RequestCode requestCode, int resultCode, Intent i) { if(uploadCallback == null) { - warn(this, "uploadCallback is null for requestCode %s", requestCode); + warn(this, "uploadCallback is null for requestCode %s", requestCode.name()); return; } @@ -112,14 +112,14 @@ public void run() { } private void takePhoto() { - a.startActivityForResult(cameraIntent(), GRAB_PHOTO_ACTIVITY_REQUEST_CODE); + a.startActivityForResult(cameraIntent(), RequestCode.GRAB_PHOTO_ACTIVITY.getCode()); } private void pickImage() { trace(this, "picking image intent"); Intent i = getPickImageIntent(a, a.getString(R.string.promptChooseImage)); trace(this, "starting activity :: %s", i); - a.startActivityForResult(i, GRAB_PHOTO_ACTIVITY_REQUEST_CODE); + a.startActivityForResult(i, RequestCode.GRAB_PHOTO_ACTIVITY.getCode()); } private boolean canStartCamera() { diff --git a/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java b/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java index cd20e557..76a272ef 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SettingsDialogActivity.java @@ -1,5 +1,9 @@ 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; + import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; @@ -8,12 +12,12 @@ import android.util.ArrayMap; 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.SimpleAdapter; import android.widget.TextView; -import android.widget.AdapterView.OnItemClickListener; import java.util.ArrayList; import java.util.LinkedList; @@ -22,26 +26,24 @@ import medic.android.ActivityBackgroundTask; -import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; -import static org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; -import static org.medicmobile.webapp.mobile.MedicLog.trace; - public class SettingsDialogActivity extends LockableActivity { 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 new AppUrlVerifier().verify(appUrl[0]); + return verifier.verify(appUrl[0]); } protected void onPostExecute(AppUrlVerification result) { SettingsDialogActivity ctx = getRequiredCtx("AppUrlVerificationTask.onPostExecute()"); @@ -258,26 +260,30 @@ void save(String url) { @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("\\."); - url = ""; + StringBuilder stringBuilder = new StringBuilder(); for(String p : parts) { - url += " "; - url += p.substring(0, 1).toUpperCase(); - url += p.substring(1); + stringBuilder.append(" "); + stringBuilder.append(p.substring(0, 1).toUpperCase()); + stringBuilder.append(p.substring(1)); } - return url.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 2dfd873c..2a2c7329 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SettingsStore.java @@ -1,11 +1,14 @@ 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.DISABLE_APP_URL_VALIDATION; +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; @@ -18,9 +21,24 @@ public abstract class SettingsStore { } public abstract String getAppUrl(); + + public String getRootUrl() { + String appUrl = getAppUrl(); + return appUrl + (DISABLE_APP_URL_VALIDATION ? "" : "/medic/_design/medic/_rewrite/"); + } + + public String getUrlToLoad(Uri url) { + return url != null ? url.toString() : getRootUrl(); + } + + public boolean isRootUrl(String url) { + return getRootUrl().equals(url); + } + public abstract boolean hasWebappSettings(); public abstract boolean allowsConfiguration(); + public abstract void update(SharedPreferences.Editor ed, WebappSettings s); String getUnlockCode() { @@ -66,8 +84,35 @@ boolean hasUserDeniedGeolocation() { void setUserDeniedGeolocation() throws SettingsException { SharedPreferences.Editor ed = prefs.edit(); ed.putBoolean("denied-geolocation", true); + if (!ed.commit()) { + throw new SettingsException("Failed to save 'denied-geolocation' to SharedPreferences."); + } + } + + /** + * Return last visited URL in the app, within TTL_LAST_URL milliseconds. + */ + String getLastUrl() { + long lastUrlTimeMillis = prefs.getLong("last-url-time-ms", 0); + long lastUrlTimeMillisFromNow = System.currentTimeMillis() - lastUrlTimeMillis; + if (lastUrlTimeMillisFromNow > TTL_LAST_URL) { + return null; + } + String lastUrl = prefs.getString("last-url", null); + trace(this, "SettingsStore() :: getting last-url: %s", lastUrl); + return lastUrl; + } + + /** + * Set last visited URL in the app. + */ + void setLastUrl(String lastUrl) throws SettingsException { + trace(this, "SettingsStore() :: setting last-url: %s", lastUrl); + SharedPreferences.Editor ed = prefs.edit(); + ed.putString("last-url", lastUrl); + ed.putLong("last-url-time-ms", System.currentTimeMillis()); if(!ed.commit()) throw new SettingsException( - "Failed to save 'denied-geolocation' to SharedPreferences."); + "Failed to save 'last-url' to SharedPreferences."); } static SettingsStore in(Context ctx) { @@ -136,7 +181,7 @@ class WebappSettings { public final String appUrl; public WebappSettings(String appUrl) { - if(DEBUG) trace(this, "WebappSettings() :: appUrl: %s", redactUrl(appUrl)); + trace(this, "WebappSettings() :: appUrl: %s", redactUrl(appUrl)); this.appUrl = appUrl; } @@ -176,6 +221,8 @@ public IllegalSetting(int componentId, int 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); } diff --git a/src/main/java/org/medicmobile/webapp/mobile/SimpleJsonClient2.java b/src/main/java/org/medicmobile/webapp/mobile/SimpleJsonClient2.java index bc58b7a8..76fb8758 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SimpleJsonClient2.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SimpleJsonClient2.java @@ -12,7 +12,6 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.net.HttpURLConnection; -import java.net.MalformedURLException; import java.net.URL; import org.json.JSONException; @@ -21,6 +20,8 @@ import static org.medicmobile.webapp.mobile.BuildConfig.DEBUG; import static org.medicmobile.webapp.mobile.BuildConfig.LOG_TAG; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + /** *

New and improved - SimpleJsonClient2 is SimpleJsonClient, but using * HttpURLConnection instead of DefaultHttpClient. @@ -33,7 +34,7 @@ public class SimpleJsonClient2 { private static final Pattern AUTH_URL = Pattern.compile("(.+)://(.*):(.*)@(.*)"); //> PUBLIC METHODS - public JSONObject get(String url) throws MalformedURLException, JSONException, IOException { + public JSONObject get(String url) throws JSONException, IOException { if(DEBUG) traceMethod("get", "url", redactUrl(url)); return get(new URL(url)); } @@ -159,6 +160,7 @@ private static void log(String methodName, String message) { Log.d(LOG_TAG, "SimpleJsonClient2." + methodName + "() :: " + message); } + @SuppressFBWarnings(value = "UPM_UNCALLED_PRIVATE_METHOD", justification = "Only called in Debug flavor") private static void log(Exception ex, String message, Object... extras) { Log.i(LOG_TAG, String.format(message, extras), ex); } diff --git a/src/main/java/org/medicmobile/webapp/mobile/SimprintsSupport.java b/src/main/java/org/medicmobile/webapp/mobile/SimprintsSupport.java deleted file mode 100644 index be79efef..00000000 --- a/src/main/java/org/medicmobile/webapp/mobile/SimprintsSupport.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.medicmobile.webapp.mobile; - -import android.app.Activity; -import android.content.Intent; - -import com.simprints.libsimprints.Identification; -import com.simprints.libsimprints.Registration; -import com.simprints.libsimprints.SimHelper; - -import java.util.List; - -import org.json.JSONArray; -import org.json.JSONException; - -import static com.simprints.libsimprints.Constants.SIMPRINTS_IDENTIFICATIONS; -import static com.simprints.libsimprints.Constants.SIMPRINTS_REGISTRATION; -import static org.medicmobile.webapp.mobile.BuildConfig.SIMPRINTS_API_KEY; -import static org.medicmobile.webapp.mobile.BuildConfig.SIMPRINTS_MODULE_ID; -import static org.medicmobile.webapp.mobile.BuildConfig.SIMPRINTS_USER_ID; -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 org.medicmobile.webapp.mobile.Utils.intentHandlerAvailableFor; -import static org.medicmobile.webapp.mobile.Utils.json; - -final class SimprintsSupport { - private static final int INTENT_TYPE_MASK = 0x7; - private static final int INTENT_ID_MASK = 0xFFFFF8; - - // future: private static final int INTENT_CONFIRM_IDENTITY = 1; - private static final int INTENT_IDENTIFY = 2; - private static final int INTENT_REGISTER = 3; - // future: private static final int INTENT_UPDATE = 4; - // future: private static final int INTENT_VERIFY = 5; - - private final Activity ctx; - - SimprintsSupport(Activity ctx) { - this.ctx = ctx; - } - - boolean isAppInstalled() { - return - intentHandlerAvailableFor(ctx, regIntent()) && - intentHandlerAvailableFor(ctx, identIntent()); - } - - void startIdent(int targetInputId) { - checkValid(targetInputId); - ctx.startActivityForResult(identIntent(), targetInputId | INTENT_IDENTIFY); - } - - void startReg(int targetInputId) { - checkValid(targetInputId); - ctx.startActivityForResult(regIntent(), targetInputId | INTENT_REGISTER); - } - - String process(int requestCode, Intent i) { - int requestType = requestCode & INTENT_TYPE_MASK; - int requestId = requestCode & INTENT_ID_MASK; - - trace(this, "process() :: requestType=%s, requestCode=%s", requestType, requestCode); - - switch(requestType) { - case INTENT_IDENTIFY: { - try { - JSONArray result = new JSONArray(); - if(i != null && i.hasExtra(SIMPRINTS_IDENTIFICATIONS)) { - List ids = i.getParcelableArrayListExtra(SIMPRINTS_IDENTIFICATIONS); - for(Identification id : ids) { - result.put(json( - "id", id.getGuid(), - "confidence", id.getConfidence(), - "tier", id.getTier() - )); - } - } - - log(this, "Simprints ident returned IDs: " + result + "; requestId=" + requestId); - - return jsResponse("identify", requestId, result); - } catch(JSONException ex) { - warn(ex, "Problem serialising simprints identifications."); - return safeFormat("console.log('Problem serialising simprints identifications: %s')", ex); - } - } - - case INTENT_REGISTER: { - try { - if(i == null || !i.hasExtra(SIMPRINTS_REGISTRATION)) return "console.log('No registration data returned from simprints app.')"; - Registration registration = i.getParcelableExtra(SIMPRINTS_REGISTRATION); - String id = registration.getGuid(); - log(this, "Simprints registration returned ID: " + id + "; requestId=" + requestCode); - return jsResponse("register", requestId, json("id", id)); - } catch(JSONException ex) { - warn(ex, "Problem serialising simprints registration result."); - return safeFormat("console.log('Problem serialising simprints registration result: %s')", ex); - } - } - - default: throw new RuntimeException("Bad request type: " + requestType); - } - } - -//> PRIVATE HELPERS - private Intent identIntent() { - return simHelper().identify(SIMPRINTS_MODULE_ID); - } - - private Intent regIntent() { - return simHelper().register(SIMPRINTS_MODULE_ID); - } - - private String jsResponse(String requestType, int requestId, Object result) { - return safeFormat("angular.element(document.body).injector().get('AndroidApi').v1.simprintsResponse('%s', '%s', '%s')", requestType, requestId, result); - } - -//> STATIC HELPERS - private static SimHelper simHelper() { - return new SimHelper(SIMPRINTS_API_KEY, SIMPRINTS_USER_ID); - } - - private static void checkValid(int targetInputId) { - if(targetInputId != (targetInputId & INTENT_ID_MASK)) throw new RuntimeException("Bad targetInputId: " + targetInputId); - } -} diff --git a/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java b/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java index 2222587c..56c1662c 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java +++ b/src/main/java/org/medicmobile/webapp/mobile/SmsSender.java @@ -77,7 +77,6 @@ private void reportStatus(Intent intent, String status, String detail) { String id = intent.getStringExtra("id"); String destination = intent.getStringExtra("destination"); String content = intent.getStringExtra("content"); - int part = intent.getIntExtra("part", -1); parent.evaluateJavascript(safeFormat( "angular.element(document.body).injector().get('AndroidApi').v1.smsStatusUpdate('%s', '%s', '%s', '%s', '%s')", diff --git a/src/webview/java/org/medicmobile/webapp/mobile/UpgradingActivity.java b/src/main/java/org/medicmobile/webapp/mobile/UpgradingActivity.java similarity index 100% rename from src/webview/java/org/medicmobile/webapp/mobile/UpgradingActivity.java rename to src/main/java/org/medicmobile/webapp/mobile/UpgradingActivity.java diff --git a/src/main/java/org/medicmobile/webapp/mobile/UrlHandler.java b/src/main/java/org/medicmobile/webapp/mobile/UrlHandler.java new file mode 100644 index 00000000..2e9fbfaf --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/UrlHandler.java @@ -0,0 +1,147 @@ +package org.medicmobile.webapp.mobile; + +import static org.medicmobile.webapp.mobile.ConnectionUtils.connectionErrorToString; +import static org.medicmobile.webapp.mobile.ConnectionUtils.isConnectionError; +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.trace; +import static org.medicmobile.webapp.mobile.Utils.isUrlRelated; +import static org.medicmobile.webapp.mobile.Utils.restartApp; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.webkit.CookieManager; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +public class UrlHandler extends WebViewClient { + EmbeddedBrowserActivity parentActivity; + SettingsStore settings; + + public UrlHandler(EmbeddedBrowserActivity parentActivity, SettingsStore settings) { + this.parentActivity = parentActivity; + this.settings = settings; + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Uri uri = request.getUrl(); + if (isUrlRelated(this.settings.getAppUrl(), uri)) { + // Load all related URLs in the WebView + return false; + } + + // Let Android decide what to do with unrelated URLs + // unrelated URLs include `tel:` and `sms:` uri schemes + Intent i = new Intent(Intent.ACTION_VIEW, uri); + view.getContext().startActivity(i); + return true; + } + + /** + * Support for SDK 21 and 22. + */ + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + logError(errorCode, description, failingUrl); + + if (!this.settings.isRootUrl(failingUrl)) { + super.onReceivedError(view, errorCode, description, failingUrl); + return; + } + + processError(view, errorCode, description); + } + + @TargetApi(23) + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + String failingUrl = request.getUrl().toString(); + String description = String.valueOf(error.getDescription()); + int code = error.getErrorCode(); + + logError(code, description, failingUrl); + + if (!this.settings.isRootUrl(failingUrl)) { + super.onReceivedError(view, request, error); + return; + } + + processError(view, code, description); + } + + private void logError(int errorCode, String errorDescription, String failingUrl) { + log(this, "onReceivedLoadError() :: url: %s, error code: %s, description: %s", + failingUrl, errorCode, errorDescription); + } + + private void processError(WebView view, int errorCode, String errorDescription) { + if (isConnectionError(errorCode)) { + startConnectionErrorActivity(view, errorCode, errorDescription); + return; + } + + this.parentActivity.evaluateJavascript(String.format( + "const body = document.evaluate('/html/body', document).iterateNext();" + + "if (body) {" + + " const content = document.createElement('div');" + + " content.innerHTML = '" + + "

Error loading page

" + + "

[%s] %s

" + + "" + + "';" + + " body.appendChild(content);" + + "}", errorCode, errorDescription), false); + } + + private void startConnectionErrorActivity(WebView view, int errorCode, String errorDescription) { + String connErrorInfo = connectionErrorToString(errorCode, errorDescription); + Intent intent = new Intent(view.getContext(), ConnectionErrorActivity.class); + intent.putExtra("connErrorInfo", connErrorInfo); + + if (this.parentActivity.isMigrationRunning()) { + // Activity is not closable if the migration is running + intent + .putExtra("isClosable", false) + .putExtra("backPressedMessage", this.parentActivity.getString(R.string.waitMigration)); + } + + this.parentActivity.startActivity(intent); + } + + /** + * Check how the migration process is going if it was started. + * Because most of the cases after the XWalk -> WebView migration process ends + * the cookies are not available for unknowns reasons, making the webapp to + * redirect the user to the login page instead of the main page. + * If these conditions are met: migration running + /login page + no cookies, + * the app is restarted to refresh the WebView and prevent the user to + * login again. + */ + @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { + boolean isMigrationRunning = this.parentActivity.isMigrationRunning(); + trace(this, "onPageStarted() :: url: %s, isMigrationRunning: %s", url, isMigrationRunning); + + if (isMigrationRunning && url.contains("/login")) { + this.parentActivity.setMigrationRunning(false); + CookieManager cookieManager = CookieManager.getInstance(); + String cookie = cookieManager.getCookie(this.settings.getAppUrl()); + if (cookie == null) { + log(this, "onPageStarted() :: Migration process in progress, and " + + "cookies were not loaded, restarting ..."); + restartApp(view.getContext()); + } + trace(this, "onPageStarted() :: Cookies loaded, skipping restart"); + } + } + + @Override public void onPageFinished(WebView view, String url) { + trace(this, "onPageFinished() :: url: %s", url); + // Broadcast the event so if the connection error + // activity is listening it will close + this.parentActivity.sendBroadcast(new Intent("onPageFinished")); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/Utils.java b/src/main/java/org/medicmobile/webapp/mobile/Utils.java index bde15a75..0bcf2a6a 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/Utils.java +++ b/src/main/java/org/medicmobile/webapp/mobile/Utils.java @@ -19,16 +19,41 @@ final class Utils { private Utils() {} + /** + * @see #isValidNavigationUrl(String, String) + */ static boolean isUrlRelated(String appUrl, Uri uriToTest) { // android.net.Uri doesn't give us a host for URLs like blob:https://some-project.dev.medicmobile.org/abc-123 // so we might as well just regex the URL string - return isUrlRelated(appUrl, uriToTest.toString()); + if (uriToTest != null) { + return isUrlRelated(appUrl, uriToTest.toString()); + } + return false; } + /** + * Valid if the URLs aren't null, and uriToTest has as prefix appUrl, + * with or without the "blob:" prefix. + */ static boolean isUrlRelated(String appUrl, String uriToTest) { // android.net.Uri doesn't give us a host for URLs like blob:https://some-project.dev.medicmobile.org/abc-123 // so we might as well just regex the URL string - return uriToTest.matches("^(blob:)?" + appUrl + "/.*$"); + if (appUrl != null && uriToTest != null) { + return uriToTest.matches("^(blob:)?" + appUrl + "/.*$"); + } + return false; + } + + /** + * Same as {@link #isUrlRelated(String, String)}, and navUrl + * isn't the login page nor a rewrite path. + */ + static boolean isValidNavigationUrl(String appUrl, String navUrl) { + boolean isValid = isUrlRelated(appUrl, navUrl); + if (isValid && !navUrl.matches(".*/(login|_rewrite).*")) { + return true; + } + return false; } static JSONObject json(Object... keyVals) throws JSONException { diff --git a/src/webview/java/org/medicmobile/webapp/mobile/XWalkMigration.java b/src/main/java/org/medicmobile/webapp/mobile/XWalkMigration.java similarity index 83% rename from src/webview/java/org/medicmobile/webapp/mobile/XWalkMigration.java rename to src/main/java/org/medicmobile/webapp/mobile/XWalkMigration.java index 6a42b3bc..0767333e 100644 --- a/src/webview/java/org/medicmobile/webapp/mobile/XWalkMigration.java +++ b/src/main/java/org/medicmobile/webapp/mobile/XWalkMigration.java @@ -6,6 +6,7 @@ import java.io.File; import static org.medicmobile.webapp.mobile.MedicLog.trace; +import static org.medicmobile.webapp.mobile.MedicLog.warn; /* * Stolen from https://github.com/dpa99c/cordova-plugin-crosswalk-data-migration @@ -106,13 +107,21 @@ public void run() { private void moveDirFromXWalkToWebView(String dirName) { File xWalkLocalStorageDir = constructFilePaths(xWalkRoot, dirName); File webviewLocalStorageDir = constructFilePaths(webviewRoot, dirName); - xWalkLocalStorageDir.renameTo(webviewLocalStorageDir); + boolean renamed = xWalkLocalStorageDir.renameTo(webviewLocalStorageDir); + + if (!renamed) { + warn(this, "XWalkMigration :: Cannot move directory from XWalk to WebView. Directory: %s", dirName); + } } private void moveDirFromXWalkToWebView(String sourceDirName, String targetDirName) { File xWalkLocalStorageDir = constructFilePaths(xWalkRoot, sourceDirName); File webviewLocalStorageDir = constructFilePaths(webviewRoot, targetDirName); - xWalkLocalStorageDir.renameTo(webviewLocalStorageDir); + boolean renamed = xWalkLocalStorageDir.renameTo(webviewLocalStorageDir); + + if (!renamed) { + warn(this, "XWalkMigration :: Cannot move directory from XWalk to WebView. XWalk directory: %s, WebView: %s", sourceDirName, targetDirName); + } } @@ -142,10 +151,6 @@ private boolean testFileExists(File root, String name) { return status; } - private File constructFilePaths(File file1, File file2) { - return constructFilePaths(file1.getAbsolutePath(), file2.getAbsolutePath()); - } - private File constructFilePaths(File file1, String file2) { return constructFilePaths(file1.getAbsolutePath(), file2); } @@ -167,10 +172,23 @@ private File getStorageRootFromFiles(File filesDir) { } private void deleteRecursive(File fileOrDirectory) { - if (fileOrDirectory.isDirectory()) - for (File child : fileOrDirectory.listFiles()) - deleteRecursive(child); + if (fileOrDirectory == null) { + return; + } - fileOrDirectory.delete(); + if (fileOrDirectory.isDirectory()) { + File[] files = fileOrDirectory.listFiles(); + if (files != null) { + for (File child : files) { + deleteRecursive(child); + } + } + } + + boolean deleted = fileOrDirectory.delete(); + + if (!deleted) { + warn(this, "XWalkMigration :: Cannot delete file or directory: %s", fileOrDirectory.getPath()); + } } } diff --git a/src/webview/res/layout/main.xml b/src/main/res/layout/main.xml similarity index 100% rename from src/webview/res/layout/main.xml rename to src/main/res/layout/main.xml diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml index c9ddf4a9..f28e5d03 100644 --- a/src/main/res/values-es/strings.xml +++ b/src/main/res/values-es/strings.xml @@ -1,7 +1,18 @@ + + Debe ser una URL válida + + Guardar + Cancelar + Continuar Más info Reintentar + Salir + + No se ha podido conectar con el servidor + La URL no es una instancia válida de CHT + CHT no está listo aún, por favor inténtelo más tarde Acceso a la ubicación %s recolecta información de la ubicación para analizar y mejorar resultados de salud en tu área. Para permitir el acceso a la ubicación selecciona \"Activar\", y luego confirma en el próximo diálogo. @@ -13,8 +24,8 @@ Actualizando la app Estás actualizando a la última versión. Por favor aguarda mientras tus datos son migrados. - No hay Conexión a Internet - Parece que no estás conectado a Internet. Por favor chequeá tu conexión y volvé a intentar. + No hay conexión a Internet + Parece que no estás conectado a Internet. Por favor comprueba tu conexión y vuelve a intentar. Por favor espere a que el proceso de migración haya finalizado diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml index e1340440..cbbc67be 100644 --- a/src/main/res/values-fr/strings.xml +++ b/src/main/res/values-fr/strings.xml @@ -1,7 +1,18 @@ + + L\'adresse n\'est pas valide + + Enregistrer + Annuler + Continuer Plus d\'information Réessayer + Quitter + + Impossible de contacter le serveur + L\'URL n\'est pas un serveur CHT valide + Le serveur CHT n\'est pas prêt, veuillez réessayer bientôt Accès à la position %s collecte votre position pour analyser et améliorer les services de santé dans votre région. Pour permettre l\'accès aux données de position, sélectionnez \"Activer\" et confirmez à l\'écran qui suivra. diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index e343505a..990326c3 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -5,13 +5,13 @@ https - CHT-Core URL - unable to contact server - URL is not a CHT-Core instance - CHT-Core is not ready, please try again soon + CHT server URL + Unable to contact server + URL is not a valid CHT server instance + CHT server is not ready, please try again soon required - must be a valid URL + Must be a valid URL OK Save @@ -39,7 +39,7 @@ Try again Code does not match - try again - Space is running low on this device. CHT-Android may not be able to start, or may perform strangely. + Space is running low on this device. Application may not be able to start, or may perform strangely. Current space:\n%d MB Recommended space:\n%d MB diff --git a/src/pih_malawi/res/values/strings.xml b/src/pih_malawi/res/values/strings.xml index 9eb88d6a..326fb949 100644 --- a/src/pih_malawi/res/values/strings.xml +++ b/src/pih_malawi/res/values/strings.xml @@ -2,4 +2,4 @@ YendaNafe pih-malawi.app.medicmobile.org - \ No newline at end of file + diff --git a/src/pih_malawi_supervisor/res/values/strings.xml b/src/pih_malawi_supervisor/res/values/strings.xml index e5c9bf1d..844b5cf7 100644 --- a/src/pih_malawi_supervisor/res/values/strings.xml +++ b/src/pih_malawi_supervisor/res/values/strings.xml @@ -2,4 +2,4 @@ YendaNafe Supervisor pih-malawi.app.medicmobile.org - \ No newline at end of file + diff --git a/src/simprints/res/values/strings.xml b/src/simprints/res/values/strings.xml deleted file mode 100644 index 1a3793a8..00000000 --- a/src/simprints/res/values/strings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - Medic - Simprints - simprints.app.medicmobile.org - diff --git a/src/test/bash/bats b/src/test/bash/bats new file mode 160000 index 00000000..90a051bf --- /dev/null +++ b/src/test/bash/bats @@ -0,0 +1 @@ +Subproject commit 90a051bf2d58432348002091bc6eb0c202a3eee7 diff --git a/src/test/bash/test-keystore.bats b/src/test/bash/test-keystore.bats new file mode 100644 index 00000000..34325308 --- /dev/null +++ b/src/test/bash/test-keystore.bats @@ -0,0 +1,127 @@ +setup() { + load 'test_helper/bats-support/load' + load 'test_helper/bats-assert/load' + # get the containing directory of this file + # use $BATS_TEST_FILENAME instead of ${BASH_SOURCE[0]} or $0, + # as those will point to the bats executable's location or the preprocessed file respectively + DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" >/dev/null 2>&1 && pwd )" + # make executables in src/ visible to PATH + PATH="$DIR/../src:$PATH" +} + +teardown() { + make RM_KEY_OPTS="-f" org=test keyrm-all +} + +@test "can execute make key* targets" { + run make keysetup + assert_success + assert_output --partial "keysetup' is up to date." +} + +@test "can't execute make key* targets when one of the tools is missing" { + run make XXD=notxxd keysetup + assert_failure 2 + refute_output --partial "keysetup' is up to date." + assert_output --partial "\"No command 'notxxd' in \$PATH\". Stop." +} + +@test "can't execute key* targets without \"org\" argument when org is required" { + run make keygen + assert_failure 2 + assert_output --partial "\"org\" name not set. Try 'make org=name keygen'. Stop." + refute_output --partial "Verifying the following executables" +} + +@test "can generate and encrypt keystore" { + run bash -c 'yes | make org=test keygen' + assert_success + refute_output --partial "\"org\" name not set. Try 'make org=name keygen'. Stop." + assert_output --partial "Verifying the following executables" + assert_output --partial "keytool -genkey -storepass" + assert_output --partial "Secrets!" + assert [ -e './test.keystore' ] + assert [ -e './test_private_key.pepk' ] + assert [ -e './secrets/secrets-test.tar.gz' ] + assert [ -e './secrets/secrets-test.tar.gz.enc' ] + ANDROID_KEYSTORE_PASSWORD_TEST=$(echo $output | grep ANDROID_KEYSTORE_PASSWORD_TEST | awk 'BEGIN { FS="=" } {print $2}') + assert [ ! -z "$ANDROID_KEYSTORE_PASSWORD_TEST" ] +} + +@test "can remove all the generated files" { + run bash -c 'yes | make org=test keygen' + make RM_KEY_OPTS="-f" org=test keyrm-all + assert_success + assert [ ! -e './test.keystore' ] + assert [ ! -e './test_private_key.pepk' ] + assert [ ! -e './secrets/secrets-test.tar.gz' ] + assert [ ! -e './secrets/secrets-test.tar.gz.enc' ] +} + +@test "can remove all the generated files except the encrypted file" { + run bash -c 'yes | make org=test keygen' + make RM_KEY_OPTS="-f" org=test keyrm + assert_success + assert [ ! -e './test.keystore' ] + assert [ ! -e './test_private_key.pepk' ] + assert [ ! -e './secrets/secrets-test.tar.gz' ] + assert [ -e './secrets/secrets-test.tar.gz.enc' ] +} + +@test "can't regenerate keystore without removing the previous one" { + # First attempt successes + run bash -c 'yes | make org=test keygen' + assert_success + # Second one fails + run bash -c 'yes | make org=test keygen' + assert_failure 2 + assert_output --partial "Files \"test.keystore\" or \"test_private_key.pepk\" already exist." +} + +@test "can't decrypt keystore when having wrong environment variable keys set" { + # Create a keystore + run bash -c 'yes | make org=test keygen' + # Removed the unencrypted files + make RM_KEY_OPTS="-f" org=test keyrm + # Set the environment variables needed to decrypt but with wrong keys + export ANDROID_KEYSTORE_PASSWORD_TEST="1232" + export ANDROID_KEY_PASSWORD_TEST="abc" + export ANDROID_SECRETS_IV_TEST="1234abc" + export ANDROID_SECRETS_KEY_TEST="111222" + export ANDROID_KEYSTORE_PATH_TEST="test.keystore" + export ANDROID_KEY_ALIAS_TEST="medicmobile" + # Now trying to decrypt without the right env sets fails + run make org=test keydec + assert_failure 2 +} + +@test "can decrypt keystore when having right environment variables set" { + # Create a keystore + run bash -c 'yes | make org=test keygen' + export out="$output" + # Read the env values needed to decrypt from the generation output, and set them in the bash environment + export ANDROID_KEYSTORE_PASSWORD_TEST=$(echo "$out" | grep ANDROID_KEYSTORE_PASSWORD_TEST | awk 'BEGIN { FS="=" } {print $2}') + export ANDROID_KEY_PASSWORD_TEST=$(echo "$out" | grep ANDROID_KEY_PASSWORD_TEST | awk 'BEGIN { FS="=" } {print $2}') + export ANDROID_SECRETS_IV_TEST=$(echo "$out" | grep ANDROID_SECRETS_IV_TEST | awk 'BEGIN { FS="=" } {print $2}') + export ANDROID_SECRETS_KEY_TEST=$(echo "$out" | grep ANDROID_SECRETS_KEY_TEST | awk 'BEGIN { FS="=" } {print $2}') + export ANDROID_KEYSTORE_PATH_TEST=$(echo "$out" | grep ANDROID_KEYSTORE_PATH_TEST | awk 'BEGIN { FS="=" } {print $2}') + export ANDROID_KEY_ALIAS_TEST=$(echo "$out" | grep ANDROID_KEY_ALIAS_TEST | awk 'BEGIN { FS="=" } {print $2}') + # Removed the unencrypted files + make RM_KEY_OPTS="-f" org=test keyrm + # Now decrypt from the encrypted version (.tar.gz.enc file) + run make org=test keydec + # The operation succeed and the unencrypted files are back + assert_success + assert [ -e './test.keystore' ] + assert [ -e './test_private_key.pepk' ] + assert [ -e './secrets/secrets-test.tar.gz' ] + # Can print the content of the cert + run make org=test keyprint + assert_output --partial "Your keystore contains 1 entry" + assert_output --partial "Alias name: $ANDROID_KEY_ALIAS_TEST" + # Changing password in the env variable the keystore cannot be accessed + export ANDROID_KEYSTORE_PASSWORD_TEST="123" + run make org=test keyprint + assert_failure + assert_output --partial "IOException: keystore password was incorrect" +} diff --git a/src/test/bash/test_helper/bats-assert b/src/test/bash/test_helper/bats-assert new file mode 160000 index 00000000..672ad182 --- /dev/null +++ b/src/test/bash/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit 672ad1823a4d2f0c475fdbec0c4497498eec5f41 diff --git a/src/test/bash/test_helper/bats-support b/src/test/bash/test_helper/bats-support new file mode 160000 index 00000000..d140a650 --- /dev/null +++ b/src/test/bash/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit d140a65044b2d6810381935ae7f0c94c7023c8c3 diff --git a/src/test/java/org/medicmobile/webapp/mobile/AppUrlVerifierTest.java b/src/test/java/org/medicmobile/webapp/mobile/AppUrlVerifierTest.java new file mode 100644 index 00000000..e3811b24 --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/AppUrlVerifierTest.java @@ -0,0 +1,145 @@ +package org.medicmobile.webapp.mobile; + +import java.io.IOException; +import java.net.MalformedURLException; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import static org.medicmobile.webapp.mobile.R.string.errAppUrl_apiNotReady; +import static org.medicmobile.webapp.mobile.R.string.errAppUrl_appNotFound; +import static org.medicmobile.webapp.mobile.R.string.errAppUrl_serverNotFound; +import static org.medicmobile.webapp.mobile.R.string.errInvalidUrl; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk=28) +public class AppUrlVerifierTest { + + private AppUrlVerifier buildAppUrlVerifier(JSONObject jsonResponse) { + try { + SimpleJsonClient2 mockJsonClient = mock(SimpleJsonClient2.class); + when(mockJsonClient.get((String) any())).thenReturn(jsonResponse); + return new AppUrlVerifier(mockJsonClient); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private AppUrlVerifier buildAppUrlVerifierWithException(Exception e) { + try { + SimpleJsonClient2 mockJsonClient = mock(SimpleJsonClient2.class); + when(mockJsonClient.get((String) any())).thenThrow(e); + return new AppUrlVerifier(mockJsonClient); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + + private AppUrlVerifier buildAppUrlVerifierOk() { + try { + return buildAppUrlVerifier(new JSONObject("{\"ready\":true,\"handler\":\"medic-api\"}")); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testCleanValidUrl() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify("https://example.com/uri"); + assertTrue(verification.isOk); + assertEquals("https://example.com/uri", verification.appUrl); + } + + @Test + public void testLeadingSpacesUrl() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify(" https://example.com/uri"); + assertTrue(verification.isOk); + assertEquals("https://example.com/uri", verification.appUrl); + } + + @Test + public void testTrailingSpacesUrl() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify("https://example.com/uri "); + assertEquals("https://example.com/uri", verification.appUrl); + } + + @Test + public void testTrailingBarsUrl() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify("https://example.com/uri/"); + assertTrue(verification.isOk); + assertEquals("https://example.com/uri", verification.appUrl); + } + + @Test + public void testOnlyLastTrailingBarIsCleaned() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify("https://example.com/uri/to/here/"); + assertTrue(verification.isOk); + assertEquals("https://example.com/uri/to/here", verification.appUrl); + } + + @Test + public void testTrailingBarsAndSpacesUrl() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify("https://example.com/uri/ "); + assertTrue(verification.isOk); + assertEquals("https://example.com/uri", verification.appUrl); + } + + @Test + public void testAllMistakesUrl() { + AppUrlVerifier verifier = buildAppUrlVerifierOk(); + AppUrlVerification verification = verifier.verify(" https://example.com/uri/res/ "); + assertTrue(verification.isOk); + assertEquals("https://example.com/uri/res", verification.appUrl); + } + + @Test + public void testMalformed() { + AppUrlVerifier verifier = buildAppUrlVerifierWithException(new JSONException("NOT A JSON")); + AppUrlVerification verification = verifier.verify("https://example.com/without/json"); + assertFalse(verification.isOk); + assertEquals(errAppUrl_appNotFound, verification.failure); + } + + @Test + public void testWrongJson() throws JSONException { + AppUrlVerifier verifier = buildAppUrlVerifier(new JSONObject("{\"data\":\"irrelevant\"}")); + AppUrlVerification verification = verifier.verify("https://example.com/setup/poll"); + assertFalse(verification.isOk); + assertEquals(errAppUrl_appNotFound, verification.failure); + } + + @Test + public void testApiNotReady() throws JSONException { + AppUrlVerifier verifier = buildAppUrlVerifier(new JSONObject("{\"ready\":false,\"handler\":\"medic-api\"}")); + AppUrlVerification verification = verifier.verify("https://example.com/setup/poll"); + assertFalse(verification.isOk); + assertEquals(errAppUrl_apiNotReady, verification.failure); + } + + @Test + public void testInvalidUrl() throws JSONException { + AppUrlVerifier verifier = buildAppUrlVerifierWithException(new MalformedURLException("Nop")); + AppUrlVerification verification = verifier.verify("\\|NOT a URL***"); + assertFalse(verification.isOk); + assertEquals(errInvalidUrl, verification.failure); + } + + @Test + public void testServerNotFound() { + AppUrlVerifier verifier = buildAppUrlVerifierWithException(new IOException("Ups")); + AppUrlVerification verification = verifier.verify("https://example.com/setup/poll"); + assertFalse(verification.isOk); + assertEquals(errAppUrl_serverNotFound, verification.failure); + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/ChtExternalAppHandlerTest.java b/src/test/java/org/medicmobile/webapp/mobile/ChtExternalAppHandlerTest.java index b782e70b..0e88681b 100644 --- a/src/test/java/org/medicmobile/webapp/mobile/ChtExternalAppHandlerTest.java +++ b/src/test/java/org/medicmobile/webapp/mobile/ChtExternalAppHandlerTest.java @@ -11,12 +11,11 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; +import androidx.core.content.ContextCompat; +import androidx.core.app.ActivityCompat; import static org.junit.Assert.assertEquals; -import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.ACCESS_STORAGE_PERMISSION_REQUEST_CODE; -import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.RequestCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -175,7 +174,7 @@ public void startIntent_withValidIntent_startsIntentCorrectly() { //> THEN verify(chtExternalApp).createIntent(); - verify(mockContext).startActivityForResult(eq(intent), eq(CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE)); + verify(mockContext).startActivityForResult(eq(intent), eq(RequestCode.CHT_EXTERNAL_APP_ACTIVITY.getCode())); } @Test @@ -198,7 +197,7 @@ public void startIntent_withException_catchesException() { //> THEN verify(chtExternalApp).createIntent(); - verify(mockContext).startActivityForResult(eq(intent), eq(CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE)); + verify(mockContext).startActivityForResult(eq(intent), eq(RequestCode.CHT_EXTERNAL_APP_ACTIVITY.getCode())); medicLogMock.verify(() -> MedicLog.error( any(), eq("ChtExternalAppHandler :: Error when starting the activity %s %s"), @@ -228,7 +227,11 @@ public void startIntent_withoutStoragePermissions_requestsPermissions() { //> THEN verify(chtExternalApp).createIntent(); contextCompatMock.verify(() -> ContextCompat.checkSelfPermission(mockContext, READ_EXTERNAL_STORAGE)); - activityCompatMock.verify(() -> ActivityCompat.requestPermissions(mockContext, new String[]{READ_EXTERNAL_STORAGE}, ACCESS_STORAGE_PERMISSION_REQUEST_CODE)); + activityCompatMock.verify(() -> ActivityCompat.requestPermissions( + mockContext, + new String[]{READ_EXTERNAL_STORAGE}, + RequestCode.ACCESS_STORAGE_PERMISSION.getCode() + )); verify(mockContext, never()).startActivityForResult(any(), anyInt()); } @@ -261,8 +264,12 @@ public void resumeActivity_withLastIntent_startsIntentCorrectly() { //> THEN verify(chtExternalApp).createIntent(); contextCompatMock.verify(() -> ContextCompat.checkSelfPermission(mockContext, READ_EXTERNAL_STORAGE)); - activityCompatMock.verify(() -> ActivityCompat.requestPermissions(mockContext, new String[]{READ_EXTERNAL_STORAGE}, ACCESS_STORAGE_PERMISSION_REQUEST_CODE)); - verify(mockContext).startActivityForResult(eq(intent), eq(CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE)); + activityCompatMock.verify(() -> ActivityCompat.requestPermissions( + mockContext, + new String[]{READ_EXTERNAL_STORAGE}, + RequestCode.ACCESS_STORAGE_PERMISSION.getCode() + )); + verify(mockContext).startActivityForResult(eq(intent), eq(RequestCode.CHT_EXTERNAL_APP_ACTIVITY.getCode())); } } diff --git a/src/test/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivityTest.java b/src/test/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivityTest.java new file mode 100644 index 00000000..2e2357e2 --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivityTest.java @@ -0,0 +1,167 @@ +package org.medicmobile.webapp.mobile; + +import static android.Manifest.permission.ACCESS_COARSE_LOCATION; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.app.Activity.RESULT_CANCELED; +import static android.app.Activity.RESULT_OK; +import static android.content.pm.PackageManager.PERMISSION_DENIED; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.RequestCode; +import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.LOCATION_PERMISSIONS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Intent; + +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.test.espresso.intent.Intents; +import androidx.test.espresso.intent.matcher.IntentMatchers; +import androidx.test.ext.junit.rules.ActivityScenarioRule; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowActivity; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk=28) +public class EmbeddedBrowserActivityTest { + + @Rule + public ActivityScenarioRule scenarioRule = new ActivityScenarioRule<>(EmbeddedBrowserActivity.class); + + @Test + public void isMigrationRunning_returnsFlagCorrectly() { + scenarioRule + .getScenario() + .onActivity(embeddedBrowserActivity -> { + embeddedBrowserActivity.setMigrationRunning(true); + assertTrue(embeddedBrowserActivity.isMigrationRunning()); + + embeddedBrowserActivity.setMigrationRunning(false); + assertFalse(embeddedBrowserActivity.isMigrationRunning()); + }); + } + + @Test + public void getLocationPermissions_withPermissionsGranted_returnsTrue() { + try( + MockedStatic contextCompatMock = mockStatic(ContextCompat.class); + MockedStatic medicLogMock = mockStatic(MedicLog.class); + ) { + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_FINE_LOCATION))).thenReturn(PERMISSION_GRANTED); + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_COARSE_LOCATION))).thenReturn(PERMISSION_GRANTED); + + scenarioRule + .getScenario() + .onActivity(embeddedBrowserActivity -> { + assertTrue(embeddedBrowserActivity.getLocationPermissions()); + Intents.times(0); + + medicLogMock.verify(() -> MedicLog.trace( + eq(embeddedBrowserActivity), + eq("getLocationPermissions() :: already granted") + )); + }); + } + } + + @Test + public void getLocationPermissions_withPermissionsDenied_returnsFalse() { + try( + MockedStatic contextCompatMock = mockStatic(ContextCompat.class); + MockedStatic medicLogMock = mockStatic(MedicLog.class); + ) { + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_FINE_LOCATION))).thenReturn(PERMISSION_DENIED); + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_COARSE_LOCATION))).thenReturn(PERMISSION_DENIED); + + scenarioRule + .getScenario() + .onActivity(embeddedBrowserActivity -> { + Intents.init(); + + assertFalse(embeddedBrowserActivity.getLocationPermissions()); + + Intents.intended(IntentMatchers.hasComponent(RequestPermissionActivity.class.getName())); + medicLogMock.verify(() -> MedicLog.trace( + eq(embeddedBrowserActivity), + eq("getLocationPermissions() :: location not granted before, requesting access...") + )); + + Intents.release(); + }); + } + } + + @Test + public void getLocationPermissions_withPermissionsDenied_requestPermissions() { + try( + MockedStatic contextCompatMock = mockStatic(ContextCompat.class); + MockedStatic activityCompatMock = mockStatic(ActivityCompat.class); + ) { + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_FINE_LOCATION))).thenReturn(PERMISSION_DENIED); + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_COARSE_LOCATION))).thenReturn(PERMISSION_DENIED); + + scenarioRule + .getScenario() + .onActivity(embeddedBrowserActivity -> { + Intents.init(); + + assertFalse(embeddedBrowserActivity.getLocationPermissions()); + Intents.intended(IntentMatchers.hasComponent(RequestPermissionActivity.class.getName())); + + ShadowActivity shadowActivity = shadowOf(embeddedBrowserActivity); + Intent requestIntent = shadowActivity.peekNextStartedActivityForResult().intent; + shadowActivity.receiveResult(requestIntent, RESULT_OK, new Intent()); + + activityCompatMock.verify(() -> ActivityCompat.requestPermissions( + embeddedBrowserActivity, + LOCATION_PERMISSIONS, + RequestCode.ACCESS_LOCATION_PERMISSION.getCode() + )); + + Intents.release(); + }); + } + } + + @Test + public void getLocationPermissions_withPermissionsAlreadyDenied_returnsFalse() { + try( + MockedStatic contextCompatMock = mockStatic(ContextCompat.class); + MockedStatic medicLogMock = mockStatic(MedicLog.class); + ) { + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_FINE_LOCATION))).thenReturn(PERMISSION_DENIED); + contextCompatMock.when(() -> ContextCompat.checkSelfPermission(any(), eq(ACCESS_COARSE_LOCATION))).thenReturn(PERMISSION_DENIED); + + scenarioRule + .getScenario() + .onActivity(embeddedBrowserActivity -> { + Intents.init(); + + assertFalse(embeddedBrowserActivity.getLocationPermissions()); + Intents.intended(IntentMatchers.hasComponent(RequestPermissionActivity.class.getName())); + + ShadowActivity shadowActivity = shadowOf(embeddedBrowserActivity); + Intent requestIntent = shadowActivity.peekNextStartedActivityForResult().intent; + shadowActivity.receiveResult(requestIntent, RESULT_CANCELED, new Intent()); + + assertFalse(embeddedBrowserActivity.getLocationPermissions()); + medicLogMock.verify(() -> MedicLog.trace( + eq(embeddedBrowserActivity), + eq("getLocationPermissions() :: user has previously denied to share location") + )); + + Intents.release(); + }); + } + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/UtilsTest.java b/src/test/java/org/medicmobile/webapp/mobile/UtilsTest.java index c0fa3dde..ee6632c9 100644 --- a/src/test/java/org/medicmobile/webapp/mobile/UtilsTest.java +++ b/src/test/java/org/medicmobile/webapp/mobile/UtilsTest.java @@ -103,5 +103,69 @@ public void getUriFromFilePath_withFileSchema_returnsUri() throws IOException { assertEquals("file", uri.getScheme()); assertEquals(filePath, uri.getPath()); } + + @Test + public void validNavigationUrls() { + final String[] goodBlobUrls = { + "https://gamma-cht.dev.medicmobile.org/some/tab", + "https://gamma-cht.dev.medicmobile.org/#/reports", + "blob:https://gamma-cht.dev.medicmobile.org/#/reports" + }; + + for(String goodBlobUrl : goodBlobUrls) { + assertTrue("Expected URL to be accepted, but it wasn't: " + goodBlobUrl, + Utils.isValidNavigationUrl("https://gamma-cht.dev.medicmobile.org", goodBlobUrl)); + } + } + + @Test + public void nullUrlsNotValid() { + final String[][] nullUrls = { + {null, null}, + {"https://gamma-cht.dev.medicmobile.org", null}, + {null, "https://gamma-cht.dev.medicmobile.org"}, + {"", ""}, + }; + + for(String[] nullUrlPair : nullUrls) { + assertFalse("Not expected URLs to be accepted, but they were: " + nullUrlPair[0] + " , " + nullUrlPair[1], + Utils.isValidNavigationUrl(nullUrlPair[0], nullUrlPair[1])); + } + } + + @Test + public void noMismatchNavigationUrl() { + assertFalse(Utils.isValidNavigationUrl( + "https://gamma-cht.dev.medicmobile.org", + "https://example.com/path")); + } + + @Test + public void notValidMalformedAppUrl() { + assertFalse(Utils.isValidNavigationUrl( + "not-valid-url", + "https://not-valid-url.com/res")); + } + + @Test + public void notValidNavigationUri() { + assertFalse(Utils.isValidNavigationUrl( + "https://gamma-cht.dev.medicmobile.org", + "/resource/without/base")); + } + + @Test + public void notValidNavigationLoginUri() { + assertFalse(Utils.isValidNavigationUrl( + "https://gamma-cht.dev.medicmobile.org", + "https://gamma-cht.dev.medicmobile.org/medic/login?")); + } + + @Test + public void notValidNavigationRewriteUri() { + assertFalse(Utils.isValidNavigationUrl( + "https://gamma-cht.dev.medicmobile.org", + "https://gamma-cht.dev.medicmobile.org/medic/_rewrite")); + } } diff --git a/src/webview/AndroidManifest.xml b/src/webview/AndroidManifest.xml deleted file mode 100644 index cd8e7232..00000000 --- a/src/webview/AndroidManifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - diff --git a/src/xwalk/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/xwalk/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java deleted file mode 100644 index 9f59425b..00000000 --- a/src/xwalk/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ /dev/null @@ -1,503 +0,0 @@ -package org.medicmobile.webapp.mobile; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.app.ActivityManager; -import android.net.Uri; -import android.net.ConnectivityManager; -import android.os.Bundle; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; -import android.view.KeyEvent; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.Window; -import android.webkit.ValueCallback; -import android.widget.Toast; -import static android.content.pm.PackageManager.PERMISSION_GRANTED; - -import java.io.ByteArrayInputStream; -import java.util.Collections; -import java.util.Map; - -import org.xwalk.core.XWalkPreferences; -import org.xwalk.core.XWalkResourceClient; -import org.xwalk.core.XWalkSettings; -import org.xwalk.core.XWalkUIClient; -import org.xwalk.core.XWalkView; -import org.xwalk.core.XWalkWebResourceRequest; -import org.xwalk.core.XWalkWebResourceResponse; - -import static java.lang.Boolean.parseBoolean; -import static org.medicmobile.webapp.mobile.BuildConfig.DISABLE_APP_URL_VALIDATION; -import static org.medicmobile.webapp.mobile.MedicLog.error; -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 org.medicmobile.webapp.mobile.SimpleJsonClient2.redactUrl; -import static org.medicmobile.webapp.mobile.Utils.createUseragentFrom; -import static org.medicmobile.webapp.mobile.Utils.isUrlRelated; - -@SuppressWarnings({ "PMD.GodClass", "PMD.TooManyMethods" }) -public class EmbeddedBrowserActivity extends LockableActivity { - /** - * Any activity result with all 3 low bits set is _not_ a simprints result. - * - * The following block of bit-shifted integers are intended for use in the subsystem seen - * in the onActivityResult below. These integers respect the reserved block of integers - * which are used by simprints. Simprint intents are started in the webapp where a matching - * bitmask is used to respect the scheme on that side of things. - * */ - private static final int NON_SIMPRINTS_FLAGS = 0x7; - static final int GRAB_PHOTO_ACTIVITY_REQUEST_CODE = (0 << 3) | NON_SIMPRINTS_FLAGS; - static final int GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE = (1 << 3) | NON_SIMPRINTS_FLAGS; - static final int DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE = (2 << 3) | NON_SIMPRINTS_FLAGS; - static final int ACCESS_STORAGE_PERMISSION_REQUEST_CODE = (3 << 3) | NON_SIMPRINTS_FLAGS; - static final int CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE = (4 << 3) | NON_SIMPRINTS_FLAGS; - - // Arbitrarily selected value - private static final int ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE = 7038678; // Arbitrarily selected value - - private static final String[] LOCATION_PERMISSIONS = { Manifest.permission.ACCESS_FINE_LOCATION }; - - private static final ValueCallback IGNORE_RESULT = new ValueCallback() { - public void onReceiveValue(String result) { /* ignore */ } - }; - - private final ValueCallback backButtonHandler = new ValueCallback() { - public void onReceiveValue(String result) { - if(!"true".equals(result)) { - EmbeddedBrowserActivity.this.moveTaskToBack(false); - } - } - }; - - private XWalkView container; - private SettingsStore settings; - private String appUrl; - private SimprintsSupport simprints; - private MrdtSupport mrdt; - private PhotoGrabber photoGrabber; - private SmsSender smsSender; - private ChtExternalAppHandler chtExternalAppHandler; - -//> ACTIVITY LIFECYCLE METHODS - @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - trace(this, "Starting XWalk webview..."); - - this.simprints = new SimprintsSupport(this); - this.photoGrabber = new PhotoGrabber(this); - this.mrdt = new MrdtSupport(this); - this.chtExternalAppHandler = new ChtExternalAppHandler(this); - try { - this.smsSender = new SmsSender(this); - } catch(Exception ex) { - error(ex, "Failed to create SmsSender."); - } - - this.settings = SettingsStore.in(this); - this.appUrl = settings.getAppUrl(); - - this.requestWindowFeature(Window.FEATURE_NO_TITLE); - setContentView(R.layout.main); - - // Add an alarming red border if using configurable (i.e. dev) - // app with a medic production server. - if(settings.allowsConfiguration() && - appUrl.contains("app.medicmobile.org")) { - View webviewContainer = findViewById(R.id.lytWebView); - webviewContainer.setPadding(10, 10, 10, 10); - webviewContainer.setBackgroundColor(R.drawable.warning_background); - } - - container = findViewById(R.id.wbvMain); - - configureUseragent(); - - setUpUiClient(container); - enableRemoteChromeDebugging(); - enableJavascript(container); - enableStorage(container); - - enableUrlHandlers(container); - - Intent appLinkIntent = getIntent(); - Uri appLinkData = appLinkIntent.getData(); - browseTo(appLinkData); - - if(settings.allowsConfiguration()) { - toast(redactUrl(appUrl)); - } - } - - @Override public boolean onCreateOptionsMenu(Menu menu) { - if(settings.allowsConfiguration()) { - getMenuInflater().inflate(R.menu.unbranded_web_menu, menu); - } else { - getMenuInflater().inflate(R.menu.web_menu, menu); - } - return super.onCreateOptionsMenu(menu); - } - - @SuppressLint("NonConstantResourceId") - @Override public boolean onOptionsItemSelected(MenuItem item) { - switch(item.getItemId()) { - case R.id.mnuGotoTestPages: - evaluateJavascript("window.location.href = 'https://medic.github.io/atp'"); - return true; - case R.id.mnuSetUnlockCode: - changeCode(); - return true; - case R.id.mnuSettings: - openSettings(); - return true; - case R.id.mnuHardRefresh: - browseTo(null); - return true; - case R.id.mnuLogout: - evaluateJavascript("angular.element(document.body).injector().get('AndroidApi').v1.logout()"); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - @Override public boolean dispatchKeyEvent(KeyEvent event) { - if(event.getKeyCode() == KeyEvent.KEYCODE_BACK) { - // With standard android WebView, this would be handled by onBackPressed(). However, that - // method does not get called when using XWalkView, so we catch the back button here instead. - // TODO this causes issues with the Samsung long-back-press to trigger menu - the menu opens, - // but the app also handles the back press :¬/ - if(event.getAction() == KeyEvent.ACTION_UP) { - container.evaluateJavascript( - "angular.element(document.body).injector().get('AndroidApi').v1.back()", - backButtonHandler); - } - - return true; - } else { - return super.dispatchKeyEvent(event); - } - } - - @Override protected void onActivityResult(int requestCode, int resultCode, Intent i) { - try { - trace(this, "onActivityResult() :: requestCode=%s, resultCode=%s", - requestCodeToString(requestCode), resultCode); - if((requestCode & NON_SIMPRINTS_FLAGS) == NON_SIMPRINTS_FLAGS) { - switch(requestCode) { - case GRAB_PHOTO_ACTIVITY_REQUEST_CODE: - photoGrabber.process(requestCode, resultCode, i); - return; - case GRAB_MRDT_PHOTO_ACTIVITY_REQUEST_CODE: - String js = mrdt.process(requestCode, resultCode, i); - trace(this, "Execing JS: %s", js); - evaluateJavascript(js); - return; - case DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE: - // User accepted or denied to allow the app to access - // location data in RequestPermissionActivity - if (resultCode == RESULT_OK) { // user accepted - // Request to Android location data access - ActivityCompat.requestPermissions( - this, - LOCATION_PERMISSIONS, - ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE); - } else if (resultCode == RESULT_CANCELED) { // user rejected - try { - this.locationRequestResolved(); - settings.setUserDeniedGeolocation(); - } catch (SettingsException e) { - error(e, "Error recording negative to access location"); - } - } - return; - case CHT_EXTERNAL_APP_ACTIVITY_REQUEST_CODE: - processChtExternalAppResult(resultCode, i); - return; - default: - trace(this, "onActivityResult() :: no handling for requestCode=%s", - requestCodeToString(requestCode)); - } - } else { - String js = simprints.process(requestCode, i); - trace(this, "Execing JS: %s", js); - evaluateJavascript(js); - } - } catch(Exception ex) { - String action = i == null ? null : i.getAction(); - warn(ex, "Problem handling intent %s (%s) with requestCode=%s & resultCode=%s", - i, action, requestCodeToString(requestCode), resultCode); - } - } - -//> ACCESSORS - SimprintsSupport getSimprintsSupport() { - return this.simprints; - } - - MrdtSupport getMrdtSupport() { - return this.mrdt; - } - - SmsSender getSmsSender() { - return this.smsSender; - } - - ChtExternalAppHandler getChtExternalAppHandler() { - return this.chtExternalAppHandler; - } - -//> PUBLIC API - public void evaluateJavascript(final String js) { - int maxUrlSize = 2097100; // Maximum character limit supported for loading as url. - - if (js.length() <= maxUrlSize) { - // `loadUrl()` seems to be significantly faster than `evaluateJavascript()` on Tecno Y4. - container.post(() -> container.load("javascript:" + js, null)); - } else { - container.post(() -> container.evaluateJavascript(js, IGNORE_RESULT)); - } - } - - public void errorToJsConsole(String message, Object... extras) { - jsConsole("error", message, extras); - } - - public void logToJsConsole(String message, Object... extras) { - jsConsole("log", message, extras); - } - -//> PRIVATE HELPERS - private String requestCodeToString(int requestCode) { - if (requestCode == ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE) { - return "ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE"; - } - - if (requestCode == DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE) { - return "DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE"; - } - - return String.valueOf(requestCode); - } - - 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 jsConsole(String type, String message, Object... extras) { - String formatted = String.format(message, extras); - String escaped = formatted.replace("'", "\\'"); - evaluateJavascript("console." + type + "('" + escaped + "');"); - } - - private void configureUseragent() { - String current = container.getUserAgentString(); - - container.setUserAgentString(createUseragentFrom(current)); - } - - private void openSettings() { - startActivity(new Intent(this, - SettingsDialogActivity.class)); - finish(); - } - - private String getRootUrl() { - return appUrl + (DISABLE_APP_URL_VALIDATION ? - "" : "/medic/_design/medic/_rewrite/"); - } - - private String getUrlToLoad(Uri url) { - if (url != null) { - return url.toString(); - } - return getRootUrl(); - } - - private void browseTo(Uri url) { - String urlToLoad = getUrlToLoad(url); - trace(this, "Pointing browser to: %s", redactUrl(urlToLoad)); - container.load(urlToLoad, null); - } - - private void enableRemoteChromeDebugging() { - XWalkPreferences.setValue(XWalkPreferences.REMOTE_DEBUGGING, true); - } - - private void setUpUiClient(XWalkView container) { - container.setUIClient(new XWalkUIClient(container) { - /** Not applicable for Crosswalk. TODO find alternative and remove this - @Override public boolean onConsoleMessage(ConsoleMessage cm) { - if(!DEBUG) { - return super.onConsoleMessage(cm); - } - - trace(this, "onConsoleMessage() :: %s:%s | %s", - cm.sourceId(), - cm.lineNumber(), - cm.message()); - return true; - } */ - - @Override public void openFileChooser(XWalkView view, ValueCallback callback, String acceptType, String shouldCapture) { - trace(this, "openFileChooser() :: view: %s, callback: %s, acceptType: %s, shouldCapture: %s", - view, callback, acceptType, shouldCapture); - - boolean capture = parseBoolean(shouldCapture); - - if(photoGrabber.canHandle(acceptType, capture)) { - photoGrabber.chooser(callback, capture); - } else { - logToJsConsole("No file chooser is currently implemented for \"accept\" value: %s", acceptType); - warn(this, "openFileChooser() :: No file chooser is currently implemented for \"accept\" value: %s", acceptType); - } - } - - /* - * TODO Crosswalk: re-enable this if required - public void onGeolocationPermissionsShowPrompt( - String origin, - GeolocationPermissions.Callback callback) { - // allow all location requests - // TODO this should be restricted to the domain - // set in Settings - issue #1603 - trace(this, "onGeolocationPermissionsShowPrompt() :: origin=%s, callback=%s", - origin, callback); - callback.invoke(origin, true, true); - } - */ - }); - } - - public boolean getLocationPermissions() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PERMISSION_GRANTED) { - trace(this, "getLocationPermissions() :: already granted"); - return true; - } - if (settings.hasUserDeniedGeolocation()) { - trace(this, "getLocationPermissions() :: user has previously denied to share location"); - this.locationRequestResolved(); - return false; - } - trace(this, "getLocationPermissions() :: location not granted before, requesting access..."); - startActivityForResult( - new Intent(this, RequestPermissionActivity.class), - DISCLOSURE_LOCATION_ACTIVITY_REQUEST_CODE); - return false; - } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - boolean granted = grantResults.length > 0 && grantResults[0] == PERMISSION_GRANTED; - - if (requestCode == ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE) { - locationRequestResolved(); - return; - } - - if (requestCode == ACCESS_STORAGE_PERMISSION_REQUEST_CODE) { - if (granted) { - this.chtExternalAppHandler.resumeActivity(); - return; - } - trace(this, "ChtExternalAppHandler :: User rejected permission."); - return; - } - } - - public void locationRequestResolved() { - evaluateJavascript( - String.format("angular.element(document.body).injector().get('AndroidApi').v1.locationPermissionRequestResolved();")); - } - - @SuppressLint("SetJavaScriptEnabled") - private void enableJavascript(XWalkView container) { - container.getSettings().setJavaScriptEnabled(true); - - MedicAndroidJavascript maj = new MedicAndroidJavascript(this); - maj.setAlert(new Alert(this)); - - maj.setActivityManager((ActivityManager) this.getSystemService(Context.ACTIVITY_SERVICE)); - - maj.setConnectivityManager((ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE)); - - container.addJavascriptInterface(maj, "medicmobile_android"); - } - - private void enableStorage(XWalkView container) { - XWalkSettings settings = container.getSettings(); - - // N.B. in Crosswalk, database seems to be enabled by default - - settings.setDomStorageEnabled(true); - - // N.B. in Crosswalk, appcache seems to work by default, and - // there is no option to set the storage path. - } - - private void enableUrlHandlers(XWalkView container) { - container.setResourceClient(new XWalkResourceClient(container) { - @Override public boolean shouldOverrideUrlLoading(XWalkView view, String url) { - if (isUrlRelated(appUrl, url)) { - // load all related URLs in XWALK - return false; - } - - // let Android decide what to do with unrelated URLs - // unrelated URLs include `tel:` and `sms:` uri schemes - Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - view.getContext().startActivity(i); - return true; - } - @Override public XWalkWebResourceResponse shouldInterceptLoadRequest(XWalkView view, XWalkWebResourceRequest request) { - if(isUrlRelated(appUrl, request.getUrl())) { - return null; // load as normal - } else { - warn(this, "shouldInterceptLoadRequest() :: Denying access to URL outside of expected domain: %s", request.getUrl()); - - Map noHeaders = Collections.emptyMap(); - ByteArrayInputStream emptyResponse = new ByteArrayInputStream(new byte[0]); - - return createXWalkWebResourceResponse( - "text/plain", "UTF-8", emptyResponse, - 403, "Read access forbidden.", noHeaders); - } - } - @Override public void onReceivedLoadError(XWalkView view, int errorCode, String description, String failingUrl) { - if(errorCode == XWalkResourceClient.ERROR_OK) return; - - log(this, "onReceivedLoadError() :: [%s] %s :: %s", - errorCode, failingUrl, description); - - if(!getRootUrl().equals(failingUrl)) { - log(this, "onReceivedLoadError() :: ignoring for non-root URL"); - } - - evaluateJavascript(String.format( - "var body = document.evaluate('/html/body', document);" + - "body = body.iterateNext();" + - "if(body) {" + - " var content = document.createElement('div');" + - " content.innerHTML = '" + - "

Error loading page

" + - "

[%s] %s

" + - "" + - "';" + - " body.appendChild(content);" + - "}", errorCode, description)); - } - }); - } - - private void toast(String message) { - Toast.makeText(container.getContext(), message, Toast.LENGTH_LONG).show(); - } -} diff --git a/src/xwalk/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/xwalk/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java deleted file mode 100644 index 44412aed..00000000 --- a/src/xwalk/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ /dev/null @@ -1,433 +0,0 @@ -package org.medicmobile.webapp.mobile; - -import android.annotation.SuppressLint; -import android.app.ActivityManager; -import android.app.ActivityManager.MemoryInfo; -import android.app.DatePickerDialog; -import android.content.pm.PackageInfo; -import android.net.ConnectivityManager; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.net.TrafficStats; -import android.net.Uri; -import android.os.Process; -import android.widget.DatePicker; -import android.os.Build; -import android.os.Environment; -import android.os.StatFs; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.HashMap; -import java.util.Map; - -import org.json.JSONException; -import org.json.JSONObject; - -import static java.util.Calendar.DAY_OF_MONTH; -import static java.util.Calendar.MONTH; -import static java.util.Calendar.YEAR; -import static java.util.Locale.UK; -import static org.medicmobile.webapp.mobile.MedicLog.log; - -public class MedicAndroidJavascript { - private static final String DATE_FORMAT = "yyyy-MM-dd"; - - private final EmbeddedBrowserActivity parent; - private final SimprintsSupport simprints; - private final MrdtSupport mrdt; - private final SmsSender smsSender; - private final ChtExternalAppHandler chtExternalAppHandler; - - private ActivityManager activityManager; - private ConnectivityManager connectivityManager; - private Alert soundAlert; - - public MedicAndroidJavascript(EmbeddedBrowserActivity parent) { - this.parent = parent; - this.simprints = parent.getSimprintsSupport(); - this.mrdt = parent.getMrdtSupport(); - this.smsSender = parent.getSmsSender(); - this.chtExternalAppHandler = parent.getChtExternalAppHandler(); - } - - public void setAlert(Alert soundAlert) { - this.soundAlert = soundAlert; - } - - public void setActivityManager(ActivityManager activityManager) { - this.activityManager = activityManager; - } - - public void setConnectivityManager(ConnectivityManager connectivityManager) { - this.connectivityManager = connectivityManager; - } - -//> JavascriptInterface METHODS - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public String getAppVersion() { - try { - return parent.getPackageManager() - .getPackageInfo(parent.getPackageName(), 0) - .versionName; - } catch(Exception ex) { - return jsonError("Error fetching app version: ", ex); - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void playAlert() { - try { - if(soundAlert != null) soundAlert.trigger(); - } catch(Exception ex) { - logException(ex); - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public String getDataUsage() { - try { - int uid = Process.myUid(); - return new JSONObject() - .put("system", getDataUsage( - TrafficStats.getTotalRxBytes(), - TrafficStats.getTotalTxBytes())) - .put("app", getDataUsage( - TrafficStats.getUidRxBytes(uid), - TrafficStats.getUidTxBytes(uid))) - .toString(); - } catch(Exception ex) { - return jsonError("Problem fetching data usage stats."); - } - } - - private JSONObject getDataUsage(long rx, long tx) throws JSONException { - return new JSONObject() - .put("rx", rx) - .put("tx", tx); - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public boolean getLocationPermissions() { - return this.parent.getLocationPermissions(); - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void datePicker(final String targetElement) { - try { - datePicker(targetElement, Calendar.getInstance()); - } catch(Exception ex) { - logException(ex); - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void datePicker(final String targetElement, String initialDate) { - try { - DateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT, UK); - Calendar c = Calendar.getInstance(); - c.setTime(dateFormat.parse(initialDate)); - datePicker(targetElement, c); - } catch(ParseException ex) { - datePicker(targetElement); - } catch(Exception ex) { - logException(ex); - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public boolean mrdt_available() { - try { - return mrdt.isAppInstalled(); - } catch(Exception ex) { - logException(ex); - return false; - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void mrdt_verify() { - try { - mrdt.startVerify(); - } catch(Exception ex) { - logException(ex); - } - } - - /** - * @return {@code true} iff an app is available to handle supported simprints {@code Intent}s - */ - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public boolean simprints_available() { - try { - return simprints.isAppInstalled(); - } catch(Exception ex) { - logException(ex); - return false; - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void simprints_ident(int targetInputId) { - try { - simprints.startIdent(targetInputId); - } catch(Exception ex) { - logException(ex); - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void simprints_reg(int targetInputId) { - try { - simprints.startReg(targetInputId); - } catch(Exception ex) { - logException(ex); - } - } - - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public boolean sms_available() { - return smsSender != null; - } - - /** - * @param id id associated with this message, e.g. a pouchdb docId - * @param destination the recipient phone number for this message - * @param content the text content of the SMS to be sent - */ - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - 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); - } catch(Exception ex) { - logException(ex); - throw ex; - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - public void launchExternalApp(String action, String category, String type, String extras, String uri, String packageName, String flags) { - try { - JSONObject parsedExtras = extras == null ? null : new JSONObject(extras); - Uri parsedUri = uri == null ? null : Uri.parse(uri); - Integer parsedFlags = flags == null ? null : Integer.parseInt(flags); - - ChtExternalApp chtExternalApp = new ChtExternalApp - .Builder() - .setAction(action) - .setCategory(category) - .setType(type) - .setExtras(parsedExtras) - .setUri(parsedUri) - .setPackageName(packageName) - .setFlags(parsedFlags) - .build(); - this.chtExternalAppHandler.startIntent(chtExternalApp); - - } catch (Exception ex) { - logException(ex); - } - } - - @org.xwalk.core.JavascriptInterface - @android.webkit.JavascriptInterface - @SuppressLint({ "NewApi", "ObsoleteSdkInt" }) - public String getDeviceInfo() { - try { - if (activityManager == null) { - return jsonError("ActivityManager not set. Cannot retrieve RAM info."); - } - - if (connectivityManager == null) { - return jsonError("ConnectivityManager not set. Cannot retrieve network info."); - } - - PackageInfo packageInfo = parent - .getPackageManager() - .getPackageInfo(parent.getPackageName(), 0); - long versionCode = Build.VERSION.SDK_INT < Build.VERSION_CODES.P ? - (long) packageInfo.versionCode : packageInfo.getLongVersionCode(); - - JSONObject appObject = new JSONObject(); - appObject.put("version", packageInfo.versionName); - appObject.put("packageName", packageInfo.packageName); - appObject.put("versionCode", versionCode); - - String androidVersion = Build.VERSION.RELEASE; - int osApiLevel = Build.VERSION.SDK_INT; - String osVersion = System.getProperty("os.version") + "(" + android.os.Build.VERSION.INCREMENTAL + ")"; - JSONObject softwareObject = new JSONObject(); - softwareObject - .put("androidVersion", androidVersion) - .put("osApiLevel", osApiLevel) - .put("osVersion", osVersion); - - String device = Build.DEVICE; - String model = Build.MODEL; - String manufacturer = Build.BRAND; - String hardware = Build.HARDWARE; - Map cpuInfo = getCPUInfo(); - JSONObject hardwareObject = new JSONObject(); - hardwareObject - .put("device", device) - .put("model", model) - .put("manufacturer", manufacturer) - .put("hardware", hardware) - .put("cpuInfo", new JSONObject(cpuInfo)); - - File dataDirectory = Environment.getDataDirectory(); - StatFs dataDirectoryStat = new StatFs(dataDirectory.getPath()); - long dataDirectoryBlockSize = dataDirectoryStat.getBlockSizeLong(); - long dataDirectoryAvailableBlocks = dataDirectoryStat.getAvailableBlocksLong(); - long dataDirectoryTotalBlocks = dataDirectoryStat.getBlockCountLong(); - long freeMemorySize = dataDirectoryAvailableBlocks * dataDirectoryBlockSize; - long totalMemorySize = dataDirectoryTotalBlocks * dataDirectoryBlockSize; - JSONObject storageObject = new JSONObject(); - storageObject - .put("free", freeMemorySize) - .put("total", totalMemorySize); - - MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); - activityManager.getMemoryInfo(memoryInfo); - long totalRAMSize = memoryInfo.totalMem; - long freeRAMSize = memoryInfo.availMem; - long thresholdRAM = memoryInfo.threshold; - JSONObject ramObject = new JSONObject(); - ramObject - .put("free", freeRAMSize) - .put("total", totalRAMSize) - .put("threshold", thresholdRAM); - - NetworkInfo netInfo = connectivityManager.getActiveNetworkInfo(); - JSONObject networkObject = new JSONObject(); - if (netInfo != null && Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { - NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.getActiveNetwork()); - int downSpeed = networkCapabilities.getLinkDownstreamBandwidthKbps(); - int upSpeed = networkCapabilities.getLinkUpstreamBandwidthKbps(); - networkObject - .put("downSpeed", downSpeed) - .put("upSpeed", upSpeed); - } - - return new JSONObject() - .put("app", appObject) - .put("software", softwareObject) - .put("hardware", hardwareObject) - .put("storage", storageObject) - .put("ram", ramObject) - .put("network", networkObject) - .toString(); - } catch(Exception ex) { - return jsonError("Problem fetching device info: ", ex); - } - } - -//> PRIVATE HELPER METHODS - private void datePicker(String targetElement, Calendar initialDate) { - // Remove single-quotes from the `targetElement` CSS selecter, as - // we'll be using these to enclose the entire string in JS. We - // are not trying to properly escape these characters, just prevent - // suprises from JS injection. - final String safeTargetElement = targetElement.replace('\'', '_'); - - DatePickerDialog.OnDateSetListener listener = new DatePickerDialog.OnDateSetListener() { - public void onDateSet(DatePicker view, int year, int month, int day) { - ++month; - String dateString = String.format(UK, "%04d-%02d-%02d", year, month, day); - String setJs = String.format("$('%s').val('%s').trigger('change')", - safeTargetElement, dateString); - parent.evaluateJavascript(setJs); - } - }; - - // Make sure that the datepicker uses spinners instead of calendars. Material design - // does not support non-calendar view, so we explicitly use the Holo theme here. - // Rumours suggest this may still show a calendar view on Android 24. This has not been confirmed. - // https://stackoverflow.com/questions/28740657/datepicker-dialog-without-calendar-visualization-in-lollipop-spinner-mode - DatePickerDialog dialog = new DatePickerDialog(parent, android.R.style.Theme_Holo_Dialog, listener, - initialDate.get(YEAR), initialDate.get(MONTH), initialDate.get(DAY_OF_MONTH)); - - DatePicker picker = dialog.getDatePicker(); - picker.setCalendarViewShown(false); - picker.setSpinnersShown(true); - - dialog.show(); - } - - private static HashMap getCPUInfo() throws IOException { - BufferedReader bufferedReader = new BufferedReader(new FileReader("/proc/cpuinfo")); - String line; - HashMap output = new HashMap(); - while ((line = bufferedReader.readLine()) != null) { - String[] data = line.split(":"); - if (data.length > 1) { - String key = data[0].trim(); - if (key.equals("model name")) { - output.put(key, data[1].trim()); - break; - } - } - } - bufferedReader.close(); - - int cores = Runtime.getRuntime().availableProcessors(); - output.put("cores", cores); - - String arch = System.getProperty("os.arch"); - output.put("arch", arch); - - return output; - } - - private void logException(Exception ex) { - log(ex, "Exception thrown in JavascriptInterface function."); - - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - ex.printStackTrace(pw); - String stacktrace = sw.toString() - .replace("\n", "; ") - .replace("\t", " "); - - parent.errorToJsConsole("Exception thrown in JavascriptInterface function: %s", stacktrace); - } - -//> STATIC HELPERS - private static String jsonError(String message, Exception ex) { - return jsonError(message + ex.getClass() + ": " + ex.getMessage()); - } - - private static String jsonError(String message) { - return "{ \"error\": true, \"message\":\"" + - jsonEscape(message) + - "\" }"; - } - - private static String jsonEscape(String s) { - return s.replaceAll("\"", "'"); - } -} diff --git a/src/xwalk/java/org/medicmobile/webapp/mobile/PhotoGrabber.java b/src/xwalk/java/org/medicmobile/webapp/mobile/PhotoGrabber.java deleted file mode 100644 index bf6aae02..00000000 --- a/src/xwalk/java/org/medicmobile/webapp/mobile/PhotoGrabber.java +++ /dev/null @@ -1,206 +0,0 @@ -package org.medicmobile.webapp.mobile; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.Matrix; -import android.net.Uri; -import android.os.AsyncTask; -import android.webkit.ValueCallback; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; - -import static android.app.Activity.RESULT_OK; -import static android.provider.MediaStore.ACTION_IMAGE_CAPTURE; -import static com.mvc.imagepicker.ImagePicker.getImageFromResult; -import static com.mvc.imagepicker.ImagePicker.getPickImageIntent; -import static org.medicmobile.webapp.mobile.EmbeddedBrowserActivity.GRAB_PHOTO_ACTIVITY_REQUEST_CODE; -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 org.medicmobile.webapp.mobile.Utils.intentHandlerAvailableFor; -import static org.medicmobile.webapp.mobile.Utils.showSpinner; - -class PhotoGrabber { - /** Max permitted file size for uploaded photos, in bytes. */ - private static final long MAX_FILE_SIZE = 1 << 15; // 32kb - /** Smallest permitted image dimension in pixels */ - private static final int MINIMUM_DIMENSION = 240; - /** - * This is actually the value of - * {@code ImagePicker.DEFAULT_REQUEST_CODE}. This value is not exposed - * by the library, but needs to be supplied to - * {@code getImageFromResult()} so that it properly returns the full- - * size image. - */ - private static final int FAKE_REQUEST_CODE = 234; - - private final Activity a; - - private ValueCallback uploadCallback; - - PhotoGrabber(Activity a) { - this.a = a; - } - -//> EXTERNAL METHODS - boolean canHandle(String acceptType, boolean capture) { - return acceptType.startsWith("image/") && (!capture || canStartCamera()); - } - - void chooser(ValueCallback callback, boolean capture) { - uploadCallback = callback; - if(capture) takePhoto(); - else pickImage(); - } - - void process(int requestCode, int resultCode, Intent i) { - if(uploadCallback == null) { - warn(this, "uploadCallback is null for requestCode %s", requestCode); - return; - } - - if(resultCode != RESULT_OK) { - String data = i == null ? null : i.getDataString(); - trace(this, "process() :: non-OK result code :: resultCode=%s, i=%s, intentData=%s", i, resultCode, data); - } else { - try { - handleBitmapCallback(getImageFromResult(a, FAKE_REQUEST_CODE, resultCode, i)); - return; - } catch(Exception ex) { - warn(ex, "process() :: error getting image from result"); - } - } - - handleNullCallback(); - } - -//> PRIVATE HELPERS - private void handleNullCallback() { - uploadCallback.onReceiveValue(null); - uploadCallback = null; - } - - private void handleBitmapCallback(final Bitmap bitmap) { - trace(this, "handleBitmapCallback() :: bitmap=%s", bitmap); - final ProgressDialog spinner = showSpinner(a, R.string.spnCompressingImage); - AsyncTask.execute(new Runnable() { - public void run() { - Uri uri = null; - try { - uri = writeToFile(bitmap); - trace(this, "process() :: image written to temp file. uri=%s", uri); - } catch(Exception ex) { - warn(ex, "process() :: error writing image to temp file"); - } - spinner.dismiss(); - - uploadCallback.onReceiveValue(uri); - uploadCallback = null; - } - }); - } - - private void takePhoto() { - a.startActivityForResult(cameraIntent(), GRAB_PHOTO_ACTIVITY_REQUEST_CODE); - } - - private void pickImage() { - Intent i = getPickImageIntent(a, a.getString(R.string.promptChooseImage)); - a.startActivityForResult(i, GRAB_PHOTO_ACTIVITY_REQUEST_CODE); - } - - private boolean canStartCamera() { - return intentHandlerAvailableFor(a, cameraIntent()); - } - - private static Intent cameraIntent() { - return new Intent(ACTION_IMAGE_CAPTURE); - } - - /** - * Write the supplied {@code Bitmap} to a random file location. - * - * Android documentation suggests that while these are nominally "temp" - * files, they may not be cleaned up automatically. For now, this may - * be considered a useful feature. - * - * @return the {@code android.net.Uri} of the created file - */ - private Uri writeToFile(Bitmap bitmap) throws IOException { - File temp = tempFile(); - long tempFileSize = -1; - int quality; - - do { - for(quality=90; quality>=30; quality-=10) { - trace(this, "writeToFile() :: Attempting to write bitmap=%s (%sx%s) to file=%s at quality=%s; size of last write=%s...", - bitmap, bitmap.getWidth(), bitmap.getHeight(), temp, quality, tempFileSize); - - writeToFile(temp, bitmap, quality); - - tempFileSize = temp.length(); - - if(tempFileSize <= MAX_FILE_SIZE) { - trace(this, "writeToFile() :: wrote temp file %s with %s bytes (max size = %s bytes)", temp, tempFileSize, MAX_FILE_SIZE); - return Uri.fromFile(temp); - } - - } - - bitmap = downsize(bitmap); - - } while(bitmap.getWidth() > MINIMUM_DIMENSION && bitmap.getHeight() > MINIMUM_DIMENSION); - - warn(this, "Failed to compress image small enough. Final quality=%s, tempFileSize=%s", quality, tempFileSize); - return null; - } - - private void writeToFile(File file, Bitmap bitmap, int quality) throws IOException { - FileOutputStream fos = null; - try { - fos = new FileOutputStream(file); - bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos); - } finally { - if(fos != null) try { - fos.close(); - } catch(IOException ex) { - warn(ex, "writeToFile() :: exception closing FileOutputStream to %s", file); - } - } - } - - /** - * @see https://stackoverflow.com/questions/4837715/how-to-resize-a-bitmap-in-android#10703256 - */ - private Bitmap downsize(Bitmap bm) { - int width = bm.getWidth(); - int height = bm.getHeight(); - - float scaleWidth = 0.5f; - float scaleHeight = 0.5f; - - // CREATE A MATRIX FOR THE MANIPULATION - Matrix matrix = new Matrix(); - // RESIZE THE BIT MAP - matrix.postScale(scaleWidth, scaleHeight); - - // "RECREATE" THE NEW BITMAP - Bitmap resizedBitmap = Bitmap.createBitmap(bm, 0, 0, width, height, matrix, false); - bm.recycle(); - return resizedBitmap; - } - - private File tempFile() throws IOException { - File imageCacheDir = new File(a.getCacheDir(), "medic-form-photos"); - boolean mkdirSuccess = imageCacheDir.mkdirs(); - if(!mkdirSuccess) { - log(this, "tempFile() :: imageCacheDir.mkdirs() failed. " + - "This may cause problems with taking photos."); - } - return File.createTempFile("photo", ".jpg", imageCacheDir); - } -} diff --git a/src/xwalk/libs/xwalk_core_library-23.53.589.4-arm64-v8a.aar b/src/xwalk/libs/xwalk_core_library-23.53.589.4-arm64-v8a.aar deleted file mode 100644 index e6af7dc7..00000000 Binary files a/src/xwalk/libs/xwalk_core_library-23.53.589.4-arm64-v8a.aar and /dev/null differ diff --git a/src/xwalk/res/layout/main.xml b/src/xwalk/res/layout/main.xml deleted file mode 100644 index 4d20645b..00000000 --- a/src/xwalk/res/layout/main.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - -