commit 3fbc678a30ee909d371ad058df289695ac9415ff Author: Amit Kumar Nandi <11887616+aamitn@users.noreply.github.com> Date: Thu Mar 7 01:32:21 2024 +0530 Initial commit GH diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e8d3a45 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,77 @@ +name: pulsebridge-app build and test + +on: [push, pull_request] + +env: + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_PATH: ${{ secrets.ANDROID_KEYSTORE_PATH }} + +jobs: + test: + name: Test + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '17' + # - name: Decrypt keystore + # run: openssl aes-256-cbc -K ${{ secrets.ANDROID_KEYSTORE_SSL_KEY }} -iv ${{ secrets.ANDROID_KEYSTORE_SSL_IV }} -in pulsebridge.jks.enc -out pulsebridge.jks -d + - name: AVD cache + uses: actions/cache@v2 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-26-default + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 26 + target: default + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + - name: Make gradlew executable + run: chmod +x ./gradlew + - name: Run unit tests + run: make test + - name: Archive results + uses: actions/upload-artifact@v2 + with: + name: Test report + path: | + build/reports/ + if: ${{ failure() }} + + build: + name: Build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '17' + - uses: android-actions/setup-android@v2 + - run: yes | sdkmanager 'platforms;android-28' + # - name: Decrypt keystore + # run: openssl aes-256-cbc -K ${{ secrets.ANDROID_KEYSTORE_SSL_KEY }} -iv ${{ secrets.ANDROID_KEYSTORE_SSL_IV }} -in pulsebridge.jks.enc -out pulsebridge.jks -d + - name: Make gradlew executables + run: chmod +x ./gradlew + - run: make assemble-release + + env: + GIT_TAG: ${{ github.ref_name }} + - name: GitHub release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + draft: true + files: ./build/outputs/apk/**/release/*.apk \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..83b91f8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,38 @@ +name: pulsebridge-app publish + +on: + push: + tags: + - v*.*.* + +env: + ANDROID_KEY_ALIAS: key0 + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_PATH: pulsebridge.jks + +jobs: + deploy: + name: Build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '17' + - uses: android-actions/setup-android@v2 + - run: yes | sdkmanager 'platforms;android-28' + # - name: Decrypt keystore + # run: openssl aes-256-cbc -K ${{ secrets.ANDROID_KEYSTORE_SSL_KEY }} -iv ${{ secrets.ANDROID_KEYSTORE_SSL_IV }} -in pulsebridge.jks.enc -out pulsebridge.jks -d + - name: Make gradlew executable + run: chmod +x ./gradlew + - run: make assemble-release + env: + GIT_TAG: ${{ github.ref_name }} + - name: GitHub release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + draft: true + files: ./build/outputs/apk/**/release/*.apk \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f18fae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Project exclude paths +/.gradle/ +/build/ +/build/intermediates/javac/genericDebug/compileGenericDebugJavaWithJavac/classes/ +/build/intermediates/javac/genericDebugUnitTest/compileGenericDebugUnitTestJavaWithJavac/classes/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..420afd4 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..fddcce7 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..425ecea --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0ad17cb --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dbbe355 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1fb7a6d --- /dev/null +++ b/Makefile @@ -0,0 +1,91 @@ +ADB = ${ANDROID_HOME}/platform-tools/adb +EMULATOR = ${ANDROID_HOME}/tools/emulator +GRADLE = ./gradlew +GRADLE_OPTS = --daemon --parallel +flavor = Medic +flavor_lower = $(shell echo ${flavor} | tr '[:upper:]' '[:lower:]') + +ifdef ComSpec # Windows + # Use `/` for all paths, except `.\` + ADB := $(subst \,/,${ADB}) + EMULATOR := $(subst \,/,${EMULATOR}) + GRADLEW := $(subst /,\,${GRADLE} ${GRADLE_OPTS}) +endif + +.PHONY: default assemble clean lint test deploy uninstall emulator kill logs force +.PHONY: all assemble-all assemble-release deploy-all uninstall-all + +default: + @ADB='${ADB}' ./scripts/assemble_and_maybe_deploy +all: clean assemble-all deploy-all + +force: assemble uninstall + adb install -r build/outputs/apk/${flavor_lower}/debug/cht-gateway-SNAPSHOT-${flavor_lower}-debug.apk + +assemble: + ${GRADLE} ${GRADLE_OPTS} assemble${flavor}Debug +assemble-all: + ${GRADLE} ${GRADLE_OPTS} assembleDebug + +assemble-release: + ${GRADLE} ${GRADLE_OPTS} assembleRelease + +clean: + rm -rf src/main/assets/ + rm -rf build/ + +lint: + ${GRADLE} ${GRADLE_OPTS} androidCheckstyle + +test: lint + IS_GENERIC_FLAVOUR=false \ + IS_MEDIC_FLAVOUR=true \ + ${GRADLE} ${GRADLE_OPTS} test${flavor}DebugUnitTest + +test-ui: assemble + ${GRADLE} ${GRADLE_OPTS} connectedGenericDebugAndroidTest + +emulator: + nohup ${EMULATOR} -avd test -wipe-data > emulator.log 2>&1 & + ${ADB} wait-for-device + +logs: + ${ADB} logcat CHTGateway:V AndroidRuntime:E '*:S' | tee android.log + +deploy: + ${GRADLE} ${GRADLE_OPTS} install${flavor}Debug +deploy-all: assemble-all + find build/outputs/apk -name \*-debug.apk | \ + xargs -n1 ${ADB} install -r + +uninstall: + adb uninstall medic.gateway.alert +uninstall-all: uninstall + adb uninstall medic.gateway.alert.generic + +kill: + pkill -9 emulator64-arm + + +.PHONY: demo-server + +demo-server: + npm install && npm start + + +.PHONY: avd changelog stats ci + +avd: + nohup android avd > /dev/null & + +stats: + ./scripts/project_stats + +changelog: + ./scripts/changelog + +ci: stats + IS_GENERIC_FLAVOUR=false \ + IS_MEDIC_FLAVOUR=true \ + ${GRADLE} ${GRADLE_OPTS} --stacktrace androidCheckstyle testMedicDebugUnitTest assemble + ${GRADLE} ${GRADLE_OPTS} connectedCheck diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7afbd8 --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +## Pulsebridge Gateway App + +[![pulsebridge-app build and test](https://github.com/aamitn/pulsebridge-app/actions/workflows/build.yml/badge.svg)](https://github.com/aamitn/pulsebridge-app/actions/workflows/build.yml) + +Download APKs from: https://github.com/medic/pulsebridge-gateway-app/releases + +--- + + +An Android application that serves as an SMS gateway. It enables the sending and receiving of SMS messages through a PulseBridge Gateway Server using an Android phone. + +```plaintext ++--------+ +-----------+ +|PBgatway| |pulsebridge| <-------- SMS +| server | <---- HTTP ---- |gateway-app| +| | | (android) | --------> SMS ++--------+ +-----------+ +``` + +## Use + +## Getting Started + +Follow these three simple steps to get started with PulseBridge Gateway: + +1.  Run PulseBridge Gateway Server by \`cloning\` the [**Repo**](https://github.com/aamitn/pulsebridge-gateway), following the [**Docs**](https://github.com/aamitn/pulsebridge-gateway/blob/master/README.md) and click `setup credentials` button. (\* Server requires valid SSL and https enabled for the mobile app to work).  +2. [**Download**](https://github.com/aamitn/pulsebridge-app/releases/download/v1.0.9/pulsebridge-app-v1.0.9-release.apk) **PulseBridge Gateway Mobile App from release section of this** [**Repo**](https://github.com/aamitn/pulsebridge-app/) **and set the URL in app provided by the server.** +3. **Send SMS from the Server Frontend or API** + + With the PulseBridge Gateway Server running, access the user-friendly interface at [https://domain.tld](http://localhost/) to send SMS messages. Alternatively, integrate the SMS functionality into your applications using the provided API. + + +## Installation + +Install the latest APK from https://github.com/medic/pulsebridge-gateway-app/releases + +## Configuration + +### PulseBridge App + +If you're configuring `pulsebridge-gateway-app` for use with hosted [`pulsebridge-gateway webserver`](https://github.com/medic/cht-core), with a URL of e.g. `https://example.com` and a username of `user`and a password of `password`, fill in the settings as follows: + +```plaintext +WebappUrl: https://user:passowrd@example.com/api/sms +``` + +### Other + +If you're configuring `pulsebridge-gateway-app` for use with other services, you will need to use the _generic_ build of `pulsebridge-gateway-app`, and find out the value for _CHT URL_ from your tech support. + +### CDMA Compatibility Mode + +Some CDMA networks have limited support for multipart SMS messages. This can occur within the same network, or only when sending SMS from a GSM network to a CDMA network. Check this box if `pulsebridge-gateway-app` is running on a GSM network and: + +* multipart messages sent to CDMA phones never arrive; or +* multipart messages sent to CDMA phones are truncated + +## Passwords + +When using HTTP Basic Auth with gateway, all characters in the password must be chosen from the [ISO-8859-1](https://en.wikipedia.org/wiki/ISO/IEC_8859-1) characterset, excluding `#`, `/`, `?`, `@`. + +## API + +This is the API specification for communications between `pulsebridge-gateway-app` and a web server. Messages in both directions are `application/json`. + +Where a list of values is expected but there are no values provided, it is acceptable to: + +* provide a `null` value; or +* provide an empty array (`[]`); or +* omit the field completely + +Bar array behaviour specified above, `pulsebridge-gateway-app` _must_ include fields specified in this document, and the web server _must_ include all expected fields in its responses. Either party _may_ include extra fields as they see fit. + +## Idempotence + +N.B. messages are considered duplicate by `pulsebridge-gateway-app` if they have identical values for `id`. The webapp is expected to do the same. + +`pulsebridge-gateway-app` will not re-process duplicate webapp-originating messages. + +`pulsebridge-gateway-app` may forward a webapp-terminating message to the webapp multiple times. + +`pulsebridge-gateway-app` may forward a delivery status report to the webapp multiple times for the same message. This should indicate a change of state, but duplicate delivery reports may be delivered in some circumstances, including: + +* the phone receives multiple delivery status reports from the mobile network for the same message +* `pulsebridge-gateway-app` failed to process the webapp's response when the delivery report was last forwarded from `pulsebridge-gateway-app` to webapp + +## Authorisation + +`pulsebridge-gateway-app` supports [HTTP Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Just include the username and password for your web endpoint when configuring `pulsebridge-gateway-app`, e.g.: + +```plaintext +https://username:password@example.com/pulsebridge-gateway-api-endpoint +``` + +## Messages + +The entire API should be implemented by a web application server at a single endpoint, e.g. https://exmaple.com/pulsebridge-gateway-app-api-endpoint + +### GET + +Expected response: + +```plaintext +{ + "pulsebridge-gateway": true +} +``` + +### POST + +`pulsebridge-gateway-app` will accept and process any relevant data received in a response. However, it may choose to only send certain types of information in a particular request (e.g. only provide a webapp-terminating SMS), and will also poll the web service periodically for webapp-originating messages, even if it has no new data to pass to the web service. + +### Request + +#### Headers + +The following headers will be set by requests: + +| header | value | +| --- | --- | +| `Accept` | `application/json` | +| `Accept-Charset` | `utf-8` | +| `Accept-Encoding` | `gzip` | +| `Cache-Control` | `no-cache` | +| `Content-Type` | `application/json` | + +Requests and responses may be sent with `Content-Encoding` set to `gzip`. + +#### Content + +```plaintext +{ + "messages": [ + { + "id": , + "from": , + "content": , + "sms_sent": , + "sms_received": + }, + ... + ], + "updates": [ + { + "id": , + "status": , + "reason": + }, + ... + ], +} +``` + +The status field is defined as follows. + +| Status | Description | +| --- | --- | +| PENDING | The message has been sent to the gateway's network | +| SENT | The message has been sent to the recipient's network | +| DELIVERED | The message has been received by the recipient's phone | +| FAILED | The delivery has failed and will not be retried | + +### Response + +#### Success + +##### HTTP Status: `2xx` + +Clients may respond with any status code in the `200`\-`299` range, as they feel is +appropriate. `pulsebridge-gateway-app` will treat all of these statuses the same. + +##### Content + +```plaintext +{ + "messages": [ + { + "id": , + "to": , + "content": + }, + ... + ], +} +``` + +#### HTTP Status `400`+ + +Response codes of `400` and above will be treated as errors. + +If the response's `Content-Type` header is set to `application/json`, `pulsebridge-gateway-app` will attempt to parse the body as JSON. The following structure is expected: + +```plaintext +{ + "error": true, + "message": +} +``` + +The `message` property may be logged and/or displayed to users in the `pulsebridge-gateway-app` UI. + +#### Other response codes + +Treatment of response codes below `200` and between `300` and `399` will _probably_ be handled sensibly by Android. + +## SMS Retry Mechanism + +Gateway will retry to send the SMS when any of these errors occurs: `RESULT_ERROR_NO_SERVICE`, `RESULT_ERROR_NULL_PDU` and `RESULT_ERROR_RADIO_OFF`. + +1. A possible temporary error occurs and Gateway retries sending the SMS: + 1.1 SMS status will be updated to `UNSENT`, so Gateway will find it and add it into the `send queue` automatically. + 1.2 SMS' `retry counter` increases by 1. + 1.3 The retry attempt is scheduled based on this formula: `SMS' last activity time + ( 1 minute * (retry counter ^ 1.5) )`. This means the time between retries is incremental. + 1.4 Gateway logs: the error, the retry counter and the retry scheduled time. Sample: `Sending SMS to +1123123123 failed (cause: radio off) Retry #5 in 15 min` +2. Gateway has a maximum limit of attempts to retry sending SMS (currently 20), If this is reached then: + 2.1 Gateway will hard fail the SMS by updating its status to `FAILED` and won't retry again. + 2.2 Gateway logs error. Sample: `Sending message to +1123123123 failed (cause: radio off) Not retrying` +3. At this point the user has the option of manually select the SMS and press `Retry` button. + 3.1 If they do and SMS fails again, then the process will restart from step # 1. + +## Development + +## Requirements + +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, how to work with "flavor" apps, release, publish... and so on. + +### `pulsebridge-gateway` ([Repo](hith)) + +* composer + +## Building locally + +More details of how to setup and build the app [here](https://docs.communityhealthtoolkit.org/core/guides/android/development-setup/). The following are the most common tasks: + +### Build and install + +To build locally and install to an attached android device: + +```plaintext +make +``` + +OR, generate release APK using  + +```plaintext +make assemble-release +``` + +OR, build using gradle + +```plaintext +gradlew build -x test +``` + +### Tests + +To run unit tests and static analysis tools locally: + +```plaintext +make test +``` + +To run end to end tests, first either connect a physical device, or start an emulated android device, and then: + +```plaintext +make test-ui +``` + +End to end tests only run in devices with Android _4.4 - 9.0_. Also it's possible that at the end of the tests when the SDK tries to uninstall the app from the device the following error is shown: + +```plaintext +com.android.build.gradle.internal.testing.ConnectedDevice > runTests[4034G - 6.0] FAILED + com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException: INSTALL_FAILED_VERSION_DOWNGRADE +``` + +Don't worry about that, it means the tests ran OK, but the SDK failed to remove the app for compatibility issues with your device, but this error only happens with the tests. + +## Android Version Support + +## "Default SMS/Messaging app" + +Some changes were made to the Android SMS APIs in 4.4 (Kitkat®). The significant change was this: + +> from android 4.4 onwards, apps cannot delete messages from the device inbox _unless they are set, by the user, as the default messaging app for the device_ + +Some reading on this can be found at: + +* http://android-developers.blogspot.com.es/2013/10/getting-your-sms-apps-ready-for-kitkat.html +* https://www.addhen.org/blog/2014/02/15/android-4-4-api-changes/ + +Adding support for kitkat® means that there is some extra code in `pulsebridge-gateway-app` whose purpose is not obvious: + +### Non-existent activities in `AndroidManifest.xml` + +Activities `HeadlessSmsSendService` and `ComposeSmsActivity` are declared in `AndroidManifest.xml`, but are not implemented in the code. + +### Unwanted permissions + +The `BROADCAST_WAP_PUSH` permission is requested in `AndroidManifest.xml`, and an extra `BroadcastReceiver`, `MmsIntentProcessor` is declared. When `pulsebridge-gateway-app` is the default messaging app on a device, incoming MMS messages will be ignored. Actual WAP Push messages are probably ignored too. + +### Extra intents + +To support being the default messaging app, `pulsebridge-gateway-app` listens for `SMS_DELIVER` as well as `SMS_RECEIVED`. If the default SMS app, we need to ignore `SMS_RECEIVED`. + +## Runtime Permissions + +Since Android 6.0 (marshmallow), permissions for sending and receiving SMS must be requested both in `AndroidManifest.xml` and also at runtime. Read more at https://developer.android.com/intl/ru/about/versions/marshmallow/android-6.0-changes.html#behavior-runtime-permissions + +## Copyright + +Copyright 2018-2024 Bitmutex Technologies \ + +## License + +The software is provided under Apache 2.0 License. Contributions to this project are accepted under the same license. \ No newline at end of file diff --git a/assets/Screenshot_20240306_223558.png b/assets/Screenshot_20240306_223558.png new file mode 100644 index 0000000..99b35d5 Binary files /dev/null and b/assets/Screenshot_20240306_223558.png differ diff --git a/assets/Screenshot_20240306_223641.png b/assets/Screenshot_20240306_223641.png new file mode 100644 index 0000000..a12f922 Binary files /dev/null and b/assets/Screenshot_20240306_223641.png differ diff --git a/assets/Screenshot_20240306_223705.png b/assets/Screenshot_20240306_223705.png new file mode 100644 index 0000000..2877779 Binary files /dev/null and b/assets/Screenshot_20240306_223705.png differ diff --git a/assets/Screenshot_20240306_223714.png b/assets/Screenshot_20240306_223714.png new file mode 100644 index 0000000..15bd70d Binary files /dev/null and b/assets/Screenshot_20240306_223714.png differ diff --git a/assets/Screenshot_20240306_223816.png b/assets/Screenshot_20240306_223816.png new file mode 100644 index 0000000..9f50147 Binary files /dev/null and b/assets/Screenshot_20240306_223816.png differ diff --git a/assets/Screenshot_20240306_223821.png b/assets/Screenshot_20240306_223821.png new file mode 100644 index 0000000..faa4bd6 Binary files /dev/null and b/assets/Screenshot_20240306_223821.png differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..92ff657 --- /dev/null +++ b/build.gradle @@ -0,0 +1,221 @@ +buildscript { + repositories { + mavenCentral() + maven { url "https://jitpack.io" } + jcenter() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.3.0' + classpath 'com.noveogroup.android:check:1.2.5', { + exclude module: 'checkstyle' + exclude module: 'xmlpull' + } + classpath 'com.puppycrawl.tools:checkstyle:7.8.1' + classpath 'com.github.bjoernq:unmockplugin:0.7.9' + } +} +apply plugin: 'com.android.application' +apply plugin: 'com.noveogroup.android.check' +apply plugin: 'de.mobilej.unmock' + +// enable verbose lint warnings +gradle.projectsEvaluated { + tasks.withType(JavaCompile) { + options.compilerArgs << + '-Xlint:deprecation' << + '-Xlint:unchecked' << + '-Xdiags:verbose' + } +} + +repositories { + maven { url "${System.env.ANDROID_HOME}/extras/android/m2repository/" } + mavenCentral() + maven { url 'https://s3.amazonaws.com/repo.commonsware.com' } + google() +} + +def excludeKxml2 = { + // This transitive dependency is excluded from some direct dependencies to prevent + // "Program type already present: org.xmlpull.v1.XmlSerializer" when running + // `gradle clean transformDexArchiveWithExternalLibsDexMergerForGenericDebugAndroidTest` + // This class is also included in the android core libs by default, so this should + // be safe to exclude from androidTestImplementation and implementation configurations. + exclude module: 'kxml2' +} + +dependencies { + testImplementation 'androidx.test:core:1.4.0' + testImplementation 'com.google.android:android-test:4.1.1.4' + testImplementation 'org.robolectric:robolectric:4.7' + testImplementation 'com.squareup.okhttp3:mockwebserver:3.2.0' + testImplementation 'org.mockito:mockito-core:4.0.0' + testImplementation 'junit:junit:4.13.2' + + androidTestImplementation 'com.google.android:android-test:4.1.1.4' + androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + + implementation 'androidx.legacy:legacy-support-core-ui:1.0.0' + implementation 'androidx.fragment:fragment:1.0.0' + implementation 'com.commonsware.cwac:wakeful:1.1.0' + implementation 'com.google.code.findbugs:annotations:3.0.1', { + // Need to exclude these, or build is broken by: + // com.android.dex.DexException: Multiple dex files define Ljavax/annotation/CheckForNull + exclude module: 'jsr305' + exclude module: 'jcip-annotations' + } + + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' +} + +def getVersionCode = { + int versionCode = 1 + if(System.env.CI == 'true' && System.env.GIT_TAG && System.env.GIT_TAG.startsWith('v')) { + def versionParts = System.env.GIT_TAG.split(/[^0-9]+/) + + if (versionParts.length != 4 && versionParts.length != 5) + throw new RuntimeException("Unexpected version number - should be of formatted as 'v1.2.3' or 'v1.2.3-alpha.4', but was: $System.env.GIT_TAG") + + versionParts = versionParts.drop(1).collect { Integer.parseInt(it) } + 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.') + + versionCode = (100 * 100 * 100 * versionParts[0]) + (100 * 100 * versionParts[1]) + (100 * versionParts[2]) + alphaPart + if (versionCode > 2100000000 / 10) + throw new RuntimeException('versionCode bigger than max allowed by Google Play.') + } + + return versionCode +} + +def getVersionName = { + System.env.GIT_TAG ?: 'SNAPSHOT' +} + +android { + compileSdkVersion 30 + buildToolsVersion '30.0.3' + packagingOptions { + resources { + excludes += ['META-INF/LICENSE', 'META-INF/NOTICE'] + } + } + + + defaultConfig { + minSdkVersion 16 + targetSdkVersion 29 // 30+ causes instrumentation tests to fail when uninstalling the app from the device + + versionCode getVersionCode() + versionName getVersionName() + archivesBaseName = "${project.name}-${versionName}" + + testInstrumentationRunner 'medic.gateway.alert.test.WakingJUnitRunner' + + multiDexEnabled true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + } + + applicationVariants.all { + buildConfigField "String", "LOG_TAG", '"CHTGateway"' + buildConfigField "boolean", "DISABLE_APP_URL_VALIDATION", "Boolean.parseBoolean(\"${System.env.DISABLE_APP_URL_VALIDATION}\")" + + buildConfigField "boolean", "CI", "Boolean.parseBoolean(\"${System.env.CI}\")" + buildConfigField "boolean", "FORCE_SEED", "Boolean.parseBoolean(\"${System.env.FORCE_SEED}\")" + buildConfigField "boolean", "LOAD_SEED_DATA", "Boolean.parseBoolean(\"${System.env.LOAD_SEED_DATA}\")" + + buildConfigField "boolean", "IS_DUMMY_SEND_AVAILABLE", "Boolean.parseBoolean(\"${System.env.ENABLE_DUMMY_SEND_OPTION}\")" + } + + sourceSets { + test { java.srcDirs = [ 'src/test/java', 'src/libTest/java' ] } + androidTest { java.srcDirs = [ 'src/androidTest/java', 'src/libTest/java' ] } + } + + signingConfigs { + release { + storeFile file(System.env.ANDROID_KEYSTORE_PATH ?: signingConfigs.debug.storeFile) + storePassword System.env.ANDROID_KEYSTORE_PASSWORD ?: signingConfigs.debug.storePassword + keyAlias System.env.ANDROID_KEY_ALIAS ?: signingConfigs.debug.keyAlias + keyPassword System.env.ANDROID_KEY_PASSWORD ?: signingConfigs.debug.keyPassword + } + } + + buildTypes { + debug { + testCoverageEnabled = true + } + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'config/proguard.pro' + signingConfig signingConfigs.release + } + } + + check { + abortOnError true + } + + + testOptions { + unitTests.includeAndroidResources true + unitTests.all { + testLogging { + events 'passed', 'skipped', 'failed', 'standardOut', 'standardError' + outputs.upToDateWhen { false } + showStandardStreams = true + } + } + } + + flavorDimensions 'brand' + productFlavors { + generic { + applicationId = 'medic.gateway.alert.generic' + buildConfigField "boolean", "IS_GENERIC_FLAVOUR", "true" + buildConfigField "boolean", "IS_MEDIC_FLAVOUR", "false" + } + medic { + applicationId = 'medic.gateway.alert' + buildConfigField "boolean", "IS_GENERIC_FLAVOUR", "false" + buildConfigField "boolean", "IS_MEDIC_FLAVOUR", "true" + } + } + namespace 'medic.gateway.alert' + lint { + abortOnError false + disable 'UnusedResources', 'GradleDependency', 'JcenterRepositoryObsolete', 'RtlHardcoded' + warningsAsErrors true + xmlReport false + } + buildFeatures { + buildConfig true + } +} + +unMock { + keep 'android.net.Uri' +} + +//Skip Findbugs +afterEvaluate { project -> + project.tasks.androidFindbugs { + onlyIf { + println 'Skipping...' + return false + } + } +} \ No newline at end of file diff --git a/config/checkstyle.xml b/config/checkstyle.xml new file mode 100644 index 0000000..a97d137 --- /dev/null +++ b/config/checkstyle.xml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/findbugs.xml b/config/findbugs.xml new file mode 100644 index 0000000..2575952 --- /dev/null +++ b/config/findbugs.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/lint.xml b/config/lint.xml new file mode 100644 index 0000000..dd0a04d --- /dev/null +++ b/config/lint.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/config/pmd.xml b/config/pmd.xml new file mode 100644 index 0000000..bd6558d --- /dev/null +++ b/config/pmd.xml @@ -0,0 +1,127 @@ + + + + + + POM rule set file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/proguard.pro b/config/proguard.pro new file mode 100644 index 0000000..65f1c33 --- /dev/null +++ b/config/proguard.pro @@ -0,0 +1 @@ +-keep public class medic.gateway.alert.AlarmListener diff --git a/generic/release/pulsebridge-app-SNAPSHOT-generic-release.aab b/generic/release/pulsebridge-app-SNAPSHOT-generic-release.aab new file mode 100644 index 0000000..61b5e40 Binary files /dev/null and b/generic/release/pulsebridge-app-SNAPSHOT-generic-release.aab differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b80c251 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +android.enableJetifier=true +android.nonFinalResIds=false +android.nonTransitiveRClass=false +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048m diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e411586 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..744e882 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# 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 +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$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" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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 + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +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" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + 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 +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 +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 + +# 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 + # 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\"" + fi + i=`expr $i + 1` + 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, 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" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..71989d6 --- /dev/null +++ b/local.properties @@ -0,0 +1,8 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +#Thu Mar 07 00:17:19 IST 2024 +sdk.dir=C\:\\Users\\bigwiz\\.android\\sdk diff --git a/pulsebridge.jks b/pulsebridge.jks new file mode 100644 index 0000000..24c7714 Binary files /dev/null and b/pulsebridge.jks differ diff --git a/scripts/analyse_poll_intervals b/scripts/analyse_poll_intervals new file mode 100644 index 0000000..1ef2f2c --- /dev/null +++ b/scripts/analyse_poll_intervals @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +/* + * Analyse poll requests from the demo server to see if cht-gateway is waking + * up as expected on a particular device. + * + * Use: + * curl https://medic-gateway-demo-server.herokuapp.com/ | jq -r '.datastore.requests[].time' | scripts/analyse_poll_intervals + * + * Options: + * -d | --discard= number of timestamps to discard from the start of the list + */ + +const SS = require('simple-statistics'); +const readline = require('readline'); +const rl = readline.createInterface({ input: process.stdin }); + +//> PROCESS ARGS +var i, discard = 0; +for(i=0; i { + timestamps.unshift(new Date(line).getTime()); +}); + +rl.on('close', () => { + // If user has requested to discard some of the early timestamps, do this now. + // This is useful if the initial timestamps are anomalous. + while(discard--) timestamps.shift(); + + const single_diffs = [], double_diffs = []; + var i; + + for(i=1; i 1) + double_diffs[i-2] = (timestamps[i] - timestamps[i-2]) / 1000; + } + + console.log('Timestamps: ' + timestamps.length); + report('SINGLE DIFFS', single_diffs); + report('DOUBLE DIFFS', double_diffs); + console.log('Finished.'); +}); diff --git a/scripts/build_and_maybe_deploy b/scripts/build_and_maybe_deploy new file mode 100644 index 0000000..753b3b4 --- /dev/null +++ b/scripts/build_and_maybe_deploy @@ -0,0 +1,11 @@ +#!/bin/bash -eu +adb_state="$($ADB get-state 2>/dev/null || true)" + +if [[ "$adb_state" = "device" ]]; then + make deploy + make logs +else + make assemble + echo "[$0] Cannot deploy - no android device or multiple devices are connected." + echo "[$0] To deploy, make sure exactly one android device is connected." +fi diff --git a/scripts/changelog b/scripts/changelog new file mode 100644 index 0000000..9700241 --- /dev/null +++ b/scripts/changelog @@ -0,0 +1,20 @@ +#!/bin/bash -eu +versions() { + git tag | grep '^v' | sort --field-separator=. --key=1,1nr --key=2,2nr --key=3,3nr +} + +if [[ $# > 0 ]]; then + current="$1" +else + current="$(versions | head -n1)" +fi +if [[ $# > 1 ]]; then + previous="$2" +else + previous="$(versions | head -n2 | tail -n1)" +fi + +echo "Changelog $previous -> $current" +echo "==========================" + +git log "${previous}..${current}" --pretty=oneline | cut -d' ' -f2- | sed -E -e 's/^/* /' diff --git a/scripts/png_transparency_tester b/scripts/png_transparency_tester new file mode 100644 index 0000000..dc5fae4 --- /dev/null +++ b/scripts/png_transparency_tester @@ -0,0 +1,49 @@ +#!/bin/bash -eu + +HTML='png_tester.html' + +cd "$(dirname "$0")/../" +mkdir -p build/reports +cd $_ + +cat > $HTML << EOF + + + PNG Transparency Tester + + + +
+
+
+EOF + +for png in $(find '../../src' -name '*.png'); do + echo "

${png:6}

" >> $HTML +done + +cat >> $HTML << EOF +
+ + + +EOF + +open "$HTML" diff --git a/scripts/project_stats b/scripts/project_stats new file mode 100644 index 0000000..af8aeaa --- /dev/null +++ b/scripts/project_stats @@ -0,0 +1,14 @@ +#!/bin/bash -eu +stats_for() { + local ext="$2" + local location="$1" + + echo "Files of type: $ext" + wc -l $(find $location -name \*."$ext") + echo +} + +stats_for src/main java +stats_for 'src/*est' java +stats_for demo-server js +stats_for src xml diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..46c88c2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'pulsebridge-app' diff --git a/src/androidTest/java/medic/gateway/alert/AlarmListenerTest.java b/src/androidTest/java/medic/gateway/alert/AlarmListenerTest.java new file mode 100644 index 0000000..522ff74 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/AlarmListenerTest.java @@ -0,0 +1,49 @@ +package medic.gateway.alert; + +import android.annotation.SuppressLint; +import android.app.*; +import android.content.*; +import android.test.*; + +import com.commonsware.cwac.wakeful.WakefulIntentService; + +import org.junit.*; + +import static android.app.PendingIntent.FLAG_ONE_SHOT; +import static org.junit.Assert.*; + +@SuppressLint("CommitPrefEdits") +@SuppressWarnings({"PMD.SignatureDeclareThrowsException"}) +public class AlarmListenerTest extends AndroidTestCase { + + @Test + public void test_scheduleAlarms_shouldDoNothing_whenThereAreNoSettings() throws Exception { + // given + noSettings(); + AlarmListener alarmListener = new AlarmListener(); + + // when + alarmListener.scheduleAlarms(alarmManager(), aPendingIntent(), getContext()); + + // then + // No exception was thrown + } + + private PendingIntent aPendingIntent() { + Intent i = new Intent(getContext(), WakefulIntentService.class); + return PendingIntent.getService(getContext(), 0, i, FLAG_ONE_SHOT); + } + + private AlarmManager alarmManager() { + return (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + } + + private void noSettings() { + SharedPreferences prefs = getContext().getSharedPreferences( + SettingsStore.class.getName(), + Context.MODE_PRIVATE); + prefs.edit().clear().commit(); + + assertNull(Settings.in(getContext())); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/ExternalLogTest.java b/src/androidTest/java/medic/gateway/alert/ExternalLogTest.java new file mode 100644 index 0000000..256cfc4 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/ExternalLogTest.java @@ -0,0 +1,262 @@ +package medic.gateway.alert; + +import android.os.Build; +import android.os.Environment; +import android.test.*; + +import java.io.*; + +import medic.gateway.alert.test.*; + +import okhttp3.mockwebserver.*; + +import org.junit.*; + +import static org.junit.Assert.*; +import static medic.gateway.alert.test.InstrumentationTestUtils.*; +import static medic.gateway.alert.test.TestUtils.*; + +@SuppressWarnings("PMD.SignatureDeclareThrowsException") +public class ExternalLogTest extends AndroidTestCase { + /** + * TODO on Android 6.0+, the permission WRITE_EXTERNAL_STORAGE needs to be + * granted at runtime for these tests to function properly. + */ + private static final boolean CANNOT_RUN_TESTS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; + + private ExternalLog xLog; + + private DbTestHelper db; + + @BeforeClass + public void santiseEnvironment() throws Exception { + removeLogFile(); + } + + @Before + public void setUp() throws Exception { + super.setUp(); + + xLog = ExternalLog.getInstance(getContext()); + db = new DbTestHelper(getContext()); + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + + removeLogFile(); + + db.tearDown(); + } + + @Test + public void test_shouldProcess_shouldReturnFalse_ifFileMissing() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + noExistingLog(); + + // expect + assertFalse(xLog.shouldProcess()); + } + + @Test + public void test_shouldProcess_shouldReturnFalse_ifFileEmpty() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + existingLog(); + + // expect + assertFalse(xLog.shouldProcess()); + } + + @Test + public void test_shouldProcess_shouldReturnTrue_ifFileHasContent() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + existingLog("some content"); + + // expect + assertTrue(xLog.shouldProcess()); + } + + @Test + public void test_lFalseog_shouldCreateFileIfMissing() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + removeLogFile(); + noExistingLog(); + assertFalse(logFile().exists()); + + // when + xLog.log(aWtMessage()); + + // then + assertTrue(logFile().exists()); + } + + @Test + public void test_log_shouldAppendToFile() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + existingLog("{\"irrelevant\":true}"); + + // when + xLog.log(aWtMessage("abc-123", 1, 2)); + + // then + assertLogContains( + "{\"irrelevant\":true}", + "{\"doc\":{\"sms_received\":2,\"sms_sent\":1,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"abc-123\"},\"type\":\"wt_message\"}"); + } + + @Test + public void process_shouldDoNothing_ifLogMissing() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + noExistingLog(); + + // when + xLog.process(getContext()); + + // then + db.assertEmpty("wt_message"); + } + + @Test + public void process_shouldDoNothing_ifLogEmpty() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + existingLog(); + + // when + xLog.process(getContext()); + + // then + db.assertEmpty("wt_message"); + } + + @Test + public void process_shouldLoadMessagesFromLog() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + existingLog( + "{ \"id\":\"abc-123\", \"from\":\"+111\", \"content\":\"message 1\", \"sms_sent\":11, \"sms_received\":111 }", + "{ \"id\":\"def-456\", \"from\":\"+222\", \"content\":\"message 2\", \"sms_sent\":22, \"sms_received\":222 }", + "{ \"id\":\"ghi-789\", \"from\":\"+333\", \"content\":\"message 3\", \"sms_sent\":33, \"sms_received\":333 }"); + + // when + xLog.process(getContext()); + + // then + db.assertTable("wt_message", + "abc-123", "WAITING", 0, "+111", "message 1", 11, 111, + "def-456", "WAITING", 0, "+222", "message 2", 22, 222, + "ghi-789", "WAITING", 0, "+333", "message 3", 33, 333); + } + + @Test + public void process_shouldIgnoreNonsense() throws Exception { + if(CANNOT_RUN_TESTS) return; + + // given + existingLog( + "any old rubbish", "", "", + + "{ \"id\":\"abc-123\", \"from\":\"+111\", \"content\":\"message 1\", \"sms_sent\":11, \"sms_received\":111 }", + + "{ \"bad-json\":true, \"from\":\"+111\", \"content\":\"message 1\", \"sms_sent\":11, \"sms_received\":111 }", + + "{ \"id\":\"def-456\", \"from\":\"+222\", \"content\":\"message 2\", \"sms_sent\":22, \"sms_received\":222 }", + + "etc. etc."); + + // when + xLog.process(getContext()); + + // then + db.assertTable("wt_message", + "abc-123", "WAITING", 0, "+111", "message 1", 11, 111, + "def-456", "WAITING", 0, "+222", "message 2", 22, 222); + } + +//> PRIVATE HELPERS + private File logFile() { + return new File(new File(Environment.getExternalStorageDirectory(), "Documents"), ".cht-gateway.json.log"); + } + + private void removeLogFile() { + File logFile = logFile(); + if(!logFile.exists()) return; + boolean success = logFile.delete(); + if(!success) throw new RuntimeException("Failed to delete log file at: " + logFile.getAbsolutePath()); + } + + private void noExistingLog() { /* nothing to do here */ } + private void existingLog(String... lines) throws Exception { + File logFile = logFile(); + + logFile.getParentFile().mkdirs(); + logFile.createNewFile(); + + FileWriter fw = null; + PrintWriter pw = null; + try { + fw = new FileWriter(logFile); + pw = new PrintWriter(fw); + + for(String line : lines) { + pw.println(line); + } + } finally { + closeOrThrow(pw); + closeOrThrow(fw); + } + } + + private void assertLogContains(String... expected) throws Exception { + FileReader fr = null; + BufferedReader br = null; + + try { + fr = new FileReader(logFile()); + br = new BufferedReader(fr); + + String actualLine; + int i = 0; + while((actualLine = br.readLine()) != null) { + assertJson("log differs from expected at line " + i, + expected[i++], + actualLine); + } + + if(i < expected.length) { + fail(String.format("File was too short. Expected %s lines, but read %s.", + expected.length, i)); + } + } finally { + closeOrThrow(br); + closeOrThrow(fr); + } + } + + private WtMessage aWtMessage() { + return new WtMessage(A_PHONE_NUMBER, SOME_CONTENT, daysAgo(2)); + } + + private WtMessage aWtMessage(String id, long sent, long received) { + return new WtMessage(id, WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, sent, received); + } + + private void closeOrThrow(Closeable c) throws Exception { + if(c != null) c.close(); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/IntentProcessorInstrumentationTest.java b/src/androidTest/java/medic/gateway/alert/IntentProcessorInstrumentationTest.java new file mode 100644 index 0000000..4496533 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/IntentProcessorInstrumentationTest.java @@ -0,0 +1,439 @@ +package medic.gateway.alert; + +import android.content.*; +import android.database.*; +import android.test.*; +import java.lang.reflect.*; +import medic.gateway.alert.test.*; +import org.junit.*; +import static android.app.Activity.RESULT_OK; +import static android.telephony.SmsManager.RESULT_ERROR_GENERIC_FAILURE; +import static android.telephony.SmsManager.RESULT_ERROR_NO_SERVICE; +import static android.telephony.SmsManager.RESULT_ERROR_NULL_PDU; +import static android.telephony.SmsManager.RESULT_ERROR_RADIO_OFF; +import static medic.gateway.alert.SmsCompatibility.SMS_DELIVER_ACTION; +import static medic.gateway.alert.WoMessage.Status.*; +import static medic.gateway.alert.test.DbTestHelper.*; +import static medic.gateway.alert.test.TestUtils.*; + +@SuppressWarnings({"PMD", "PMD.SignatureDeclareThrowsException", "PMD.JUnitTestsShouldIncludeAssert"}) +public class IntentProcessorInstrumentationTest extends AndroidTestCase { + private IntentProcessor intentProcessor; + + private DbTestHelper db; + private HttpTestHelper http; + + @Before + public void setUp() throws Exception { + super.setUp(); + + this.db = new DbTestHelper(getContext()); + + this.intentProcessor = new IntentProcessor(); + + http = new HttpTestHelper(); + http.configureAppSettings(getContext()); + + dummySend(true); + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + + db.tearDown(); + http.tearDown(); + + http.assertNoMoreRequests(); + + dummySend(false); + } + + public void dummySend(boolean dummySend) { + SharedPreferences.Editor ed = getContext() + .getSharedPreferences(SettingsStore.class.getName(), Context.MODE_PRIVATE) + .edit(); + ed.putBoolean("dummy-send-enabled", dummySend); + assertTrue(ed.commit()); + } + +//> REQUEST CONTENT TESTS + @Test + public void test_onReceive_shouldUpdateSendStatusOfWoMessage() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 0); + + // when + aSendingReportArrivesFor(id); + + // then + assertWoDbStatusOf(id, SENT); + } + + @Test + public void test_onReceive_GENERIC_shouldUpdateSendStatusAndIncludeErrorCodeInReason() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 0); + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_GENERIC_FAILURE, 99); + + // then + db.assertTable("wo_message", + id, "FAILED", "generic; errorCode=99", ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "FAILED", "generic; errorCode=99", ANY_NUMBER, true); + } + + @Test + public void test_onReceive_RADIO_OFF_shouldUpdateSendStatusAndIncludeErrorCodeInReason() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 21); // Hard fail + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 21); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_RADIO_OFF); + + // then + db.assertTable("wo_message", + id, "FAILED", "radio-off", ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "FAILED", "radio-off", ANY_NUMBER, true); + } + + @Test + public void test_onReceive_RADIO_OFF_shouldUpdateSendStatusAndRetryAfterSoftFail() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 0); + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_RADIO_OFF); + + // then + db.assertTable("wo_message", + id, "UNSENT", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 1); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "UNSENT", null, ANY_NUMBER, true); + } + + @Test + public void test_onReceive_NO_SERVICE_shouldUpdateSendStatusAndIncludeErrorCodeInReason() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 21); // Hard fail + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 21); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_NO_SERVICE); + + // then + db.assertTable("wo_message", + id, "FAILED", "no-service", ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "FAILED", "no-service", ANY_NUMBER, true); + } + + @Test + public void test_onReceive_NO_SERVICE_shouldUpdateSendStatusAndRetryAfterSoftFail() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 0); + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_NO_SERVICE); + + // then + db.assertTable("wo_message", + id, "UNSENT", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 1); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "UNSENT", null, ANY_NUMBER, true); + } + + @Test + public void test_onReceive_NULL_PDU_shouldUpdateSendStatusAndIncludeErrorCodeInReason() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 21); // Hard fail + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 21); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_NULL_PDU); + + // then + db.assertTable("wo_message", + id, "FAILED", "null-pdu", ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "FAILED", "null-pdu", ANY_NUMBER, true); + } + + @Test + public void test_onReceive_NULL_PDU_shouldUpdateSendStatusAndRetryAfterSoftFail() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 0); + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_NULL_PDU); + + // then + db.assertTable("wo_message", + id, "UNSENT", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 1); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "UNSENT", null, ANY_NUMBER, true); + } + + @Test + public void test_onReceive_NULL_PDU_shouldContinueRetryAfterSoftFail() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 10); + db.assertTable("wo_message", + id, "PENDING", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 10); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false); + + // when + aSendFailureReportArrivesFor(id, RESULT_ERROR_NULL_PDU); + + // then + db.assertTable("wo_message", + id, "UNSENT", null, ANY_NUMBER, ANY_PHONE_NUMBER, ANY_CONTENT, 11); + db.assertTable("wom_status", + ANY_NUMBER, id, "PENDING", null, ANY_NUMBER, false, + ANY_NUMBER, id, "UNSENT", null, ANY_NUMBER, true); + } + + @Test + public void test_onReceive_shouldUpdateDeliveryStatusOfSentWoMessage() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, SENT, 0); + + // when + aDeliveryReportArrivesFor(id); + + // then + assertWoDbStatusOf(id, DELIVERED); + } + + @Test + public void test_onReceive_shouldUpdateDeliveryStatusOfPendingWoMessage() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, PENDING, 0); + + // when + aDeliveryReportArrivesFor(id); + + // then + assertWoDbStatusOf(id, DELIVERED); + } + + @Test + public void test_onReceive_shouldNotUpdateStatusOfAlreadyDeliveredMessage() throws Exception { + // given + String id = randomUuid(); + aWoMessageIsInDbWith(id, DELIVERED, 0); + + // when + aSendFailureReportArrivesFor(id); + + // then + assertWoDbStatusOf(id, DELIVERED); + } + + @Test + public void test_onDeliver_shouldForwardToApiAndSendSMSResponses() throws Exception { + // given + // http.nextResponseJson("{}"); + http.nextResponseJson("{ \"messages\": [ " + + "{ \"id\": \"aaa-111\", \"to\": \"+1\", \"content\": \"testing: one\" }," + + "] }"); + + + // when + aWtSmsArrives(); + + // we need to wait for an async task to complete + // this is hacky, but if we find we need it more we can formalise it + int attemptsLeft = 10; + while (true) { + try { + // we're using our last assertion to confirm the entire async flow has completed + assertWoDbStatusOf("aaa-111", WoMessage.Status.DELIVERED); + break; + } catch (Error e) { // junit failures extend Error + if (attemptsLeft == 0) { + throw e; + } + + Thread.sleep(100); + attemptsLeft--; + } + } + + // then + http.assertPostRequestMade_withJsonResponse(); + assertWtDbStatus(WtMessage.Status.FORWARDED); + + assertWoDbStatusOf("aaa-111", WoMessage.Status.DELIVERED); + } + +//> HELPER METHODS + private void aWoMessageIsInDbWith(String id, WoMessage.Status status, int retries) { + db.insert("wo_message", + cols("_id", "status", "last_action", "_to", "content", "retries"), + vals(id, status, 0, A_PHONE_NUMBER, SOME_CONTENT, retries)); + db.insert("wom_status", + cols("message_id", "status", "timestamp", "needs_forwarding"), + vals(id, status, 0, false)); + } + + private void assertWoDbStatusOf(String id, WoMessage.Status expectedStatus) { + Cursor c = db.raw.rawQuery("SELECT status FROM wo_message WHERE _id=?", args(id)); + assertEquals(1, c.getCount()); + + c.moveToFirst(); + assertEquals(expectedStatus.toString(), c.getString(0)); + + c.close(); + } + + private void assertWtDbStatus(WtMessage.Status expectedStatus) { + // Some in the expectedStatus + Cursor c = db.raw.rawQuery("SELECT status FROM wt_message", NO_ARGS); + + c.moveToFirst(); + + assertTrue(c.getCount() > 0); + + while (!c.isAfterLast()) { + assertEquals(expectedStatus.toString(), c.getString(0)); + c.moveToNext(); + } + + c.close(); + } + + private void aDeliveryReportArrivesFor(String id) { + Intent i = intentFor("medic.gateway.alert.DELIVERY_REPORT", id); + i.putExtra("format", "3gpp"); + i.putExtra("pdu", A_VALID_DELIVERED_REPORT); + deliver(i); + } + + private void aSendingReportArrivesFor(String id) { + deliver(intentFor("medic.gateway.alert.SENDING_REPORT", id), RESULT_OK); + } + + private void aSendFailureReportArrivesFor(String id) { + aSendFailureReportArrivesFor(id, RESULT_ERROR_GENERIC_FAILURE); + } + + private void aSendFailureReportArrivesFor(String id, int resultCode) { + deliver(intentFor("medic.gateway.alert.SENDING_REPORT", id), resultCode); + } + + private void aSendFailureReportArrivesFor(String id, int resultCode, int errorCode) { + Intent sendIntent = intentFor("medic.gateway.alert.SENDING_REPORT", id); + sendIntent.putExtra("errorCode", errorCode); + deliver(sendIntent, RESULT_ERROR_GENERIC_FAILURE); + } + + private void aWtSmsArrives() { + Intent i = new Intent(SMS_DELIVER_ACTION); + i.putExtra("pdus", new Object[] {A_VALID_GSM_PDU}); + i.putExtra("format", "3gpp"); + deliver(i); + } + + private Intent intentFor(String action, String id) { + Intent i = new Intent(action); + i.putExtra("id", id); + return i; + } + + private void deliver(Intent i, int resultCode) { + try { + Constructor c = BroadcastReceiver.PendingResult.class.getDeclaredConstructor(int.class, String.class, android.os.Bundle.class, int.class, boolean.class, boolean.class, android.os.IBinder.class, int.class); + c.setAccessible(true); + BroadcastReceiver.PendingResult pr = (BroadcastReceiver.PendingResult) c.newInstance(resultCode, null, null, 0, false, false, null, 0); + + Field f = BroadcastReceiver.class.getDeclaredField("mPendingResult"); + f.setAccessible(true); + f.set(intentProcessor, pr); + + deliver(i); + + return; + } catch(Exception ex) { /* ignore */ } + + try { + Constructor c = BroadcastReceiver.PendingResult.class.getDeclaredConstructor(int.class, String.class, android.os.Bundle.class, int.class, boolean.class, boolean.class, android.os.IBinder.class, int.class, int.class); + c.setAccessible(true); + BroadcastReceiver.PendingResult pr = (BroadcastReceiver.PendingResult) c.newInstance(resultCode, null, null, 0, false, false, null, 0, 0); + + Field f = BroadcastReceiver.class.getDeclaredField("mPendingResult"); + f.setAccessible(true); + f.set(intentProcessor, pr); + + deliver(i); + + return; + } catch(Exception ex) { /* ignore */ } + + StringBuilder details = new StringBuilder(); + for(Constructor c : BroadcastReceiver.PendingResult.class.getDeclaredConstructors()) { + details.append(c.getName()); + details.append('('); + for(Class p : c.getParameterTypes()) { + details.append(p.getName()); + details.append(','); + } + details.append("); "); + } + throw new RuntimeException("Looks like this version of Android isn't properly supported by this test :¬\\ " + + "Maybe one of these contructors will help: " + details); + + } + + private void deliver(Intent i) { + intentProcessor.onReceive(getContext(), i); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/SettingsDialogActivityTest.java b/src/androidTest/java/medic/gateway/alert/SettingsDialogActivityTest.java new file mode 100644 index 0000000..6a672e7 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/SettingsDialogActivityTest.java @@ -0,0 +1,389 @@ +package medic.gateway.alert; + +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.*; +import androidx.test.filters.LargeTest; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import medic.gateway.alert.test.*; +import org.junit.*; +import org.junit.runner.*; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.*; +import static androidx.test.espresso.assertion.ViewAssertions.*; +import static androidx.test.espresso.matcher.ViewMatchers.*; +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static medic.gateway.alert.BuildConfig.IS_MEDIC_FLAVOUR; +import static medic.gateway.alert.R.*; +import static medic.gateway.alert.test.InstrumentationTestUtils.*; + +@LargeTest +@RunWith(AndroidJUnit4.class) +@SuppressWarnings({"PMD.SignatureDeclareThrowsException", "PMD.JUnitTestsShouldIncludeAssert", "PMD.GodClass", "PMD.TooManyMethods"}) +public class SettingsDialogActivityTest { + private static final boolean NOT_MEDIC_FLAVOUR = !IS_MEDIC_FLAVOUR; + private static final boolean NOT_GENERIC_FLAVOUR = IS_MEDIC_FLAVOUR; + + @Rule @SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") + public ActivityScenarioRule activityTestRule = + new ActivityScenarioRule<>(SettingsDialogActivity.class); + + private HttpTestHelper http; + + @Before + public void setUp() throws Throwable { + clearAppSettings(); + this.http = new HttpTestHelper(); + } + + @After + public void tearDown() throws Exception { + clearAppSettings(); + } + + +//> GENERIC FLAVOUR TESTS + @Test + public void generic_shouldDisplayCorrectFields() throws Exception { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // expect + assertVisible(id.txtWebappUrl); + assertVisible(id.cbxEnablePolling); + assertVisible(id.cbxEnableCdmaCompatMode); + assertVisible(id.btnSaveSettings); + + assertDoesNotExist(id.txtWebappInstanceName); + assertDoesNotExist(id.txtWebappPassword); + } + + @Test + public void generic_shouldDisplayCancelButtonIfSettingsExist() throws Exception { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + settingsStore().save(new Settings(http.url(), true, false, false)); + + // when + recreateActivityFor(activityTestRule); + + // then + assertVisible(id.btnCancelSettings); + } + + @Test + public void generic_shouldNotDisplayCancelButtonIfSettingsDoNotExist() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // expect + onView(withId(id.btnCancelSettings)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void generic_leavingUrlBlankShouldShowError() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + urlEnteredAs(""); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errRequired); + } + + @Test + public void generic_enteringInvalidUrlShouldShowError() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + urlEnteredAs("nonsense"); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errInvalidUrl); + } + + @Test + public void generic_enteringUrlWhichDoesNotRespondShouldShowError() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + urlEnteredAs("http://not-a-real-domain-i-hope.com"); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errWebappUrl_serverNotFound); + } + + @Test + public void generic_enteringUrlWhichRespondsIncorrectlyShouldShowError() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + http.nextResponseJson("{ \"bad\": true }"); + urlEnteredAs(http.url()); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errWebappUrl_appNotFound); + } + + @Test + public void generic_enteringUrlWhichRespondsWithUnauthorisedShouldShowError() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + http.nextResponseError(401); + urlEnteredAs(http.url()); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errWebappUrl_unauthorised); + } + + @Test + public void generic_enteringGoodUrlShouldSaveWebappUrl() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + assertFalse(settingsStore().hasSettings()); + http.nextResponseJson("{ \"medic-gateway\": true }"); + urlEnteredAs(http.url()); + + // when + saveClicked(); + + // then + assertTrue(settingsStore().hasSettings()); + assertEquals(http.url(), settings().webappUrl); + } + + @Test + public void generic_disablingPollingShouldSave() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + assertFalse(settingsStore().hasSettings()); + http.nextResponseJson("{ \"error\": \"this url should not be contacted if polling disabled\" }"); + urlEnteredAs(http.url()); + uncheckPollingEnabled(); + + // when + saveClicked(); + + // then + assertTrue(settingsStore().hasSettings()); + assertFalse(settings().pollingEnabled); + } + + @Test + public void generic_enablingPollingShouldSave() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + assertFalse(settingsStore().hasSettings()); + http.nextResponseJson("{ \"medic-gateway\": true }"); + urlEnteredAs(http.url()); + checkPollingEnabled(); + + // when + saveClicked(); + + // then + assertTrue(settingsStore().hasSettings()); + assertTrue(settings().pollingEnabled); + } + + @Test + public void generic_cdmaCompat_shouldBeDisabledByDefault() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + assertFalse(settingsStore().hasSettings()); + http.nextResponseJson("{ \"medic-gateway\": true }"); + urlEnteredAs(http.url()); + assertNotChecked(id.cbxEnableCdmaCompatMode); + + // when + saveClicked(); + + // then + assertTrue(settingsStore().hasSettings()); + assertFalse(settings().cdmaCompatMode); + } + + @Test + public void generic_cdmaCompat_shouldBeEnablable() { + if(NOT_GENERIC_FLAVOUR) /* test not applicable */ return; + + // given + assertFalse(settingsStore().hasSettings()); + http.nextResponseJson("{ \"medic-gateway\": true }"); + urlEnteredAs(http.url()); + checkCdmaCompatEnabled(); + + // when + saveClicked(); + + // then + assertTrue(settingsStore().hasSettings()); + assertTrue(settings().cdmaCompatMode); + } + +//> MEDIC FLAVOUR TESTS + @Test + public void medic_shouldDisplayCorrectFields() throws Exception { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // expect + assertVisible(id.txtWebappInstanceName); + assertVisible(id.txtWebappPassword); + + assertVisible(id.cbxEnablePolling); + assertVisible(id.cbxEnableCdmaCompatMode); + assertVisible(id.btnSaveSettings); + + assertDoesNotExist(id.txtWebappUrl); + } + + @Test + public void medic_shouldDisplayCancelButtonIfSettingsExist() throws Exception { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // given + settingsStore().save(new Settings("https://uname:pword@test.dev.medicmobile.org/api/sms", true, false, false)); + + // when + recreateActivityFor(activityTestRule); + + // then + assertVisible(id.btnCancelSettings); + } + + @Test + public void medic_shouldNotDisplayCancelButtonIfSettingsDoNotExist() { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // given + clearAppSettings(); + + // when + recreateActivityFor(activityTestRule); + + // expect + onView(withId(id.btnCancelSettings)) + .check(matches(not(isDisplayed()))); + } + + @Test + public void medic_leavingWebappInstanceNameBlankShouldShowError() { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // given + webappInstanceNameEnteredAs(""); + passwordEnteredAs("some-password"); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errRequired); + } + + @Test + public void medic_leavingPasswordBlankShouldShowError() { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // given + webappInstanceNameEnteredAs("some.instance"); + passwordEnteredAs(""); + + // when + saveClicked(); + + // then + assertErrorDisplayed(id.txtWebappPassword, string.errRequired); + } + + @Test + public void medic_enteringBadInstanceNameShouldShowError() { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // given + webappInstanceNameEnteredAs("..."); + passwordEnteredAs("pass"); + + // when + saveClicked(); + + // then + assertErrorDisplayed(string.errInvalidInstanceName); + } + + @Test + public void medic_disablingPollingShouldSave() { + if(NOT_MEDIC_FLAVOUR) /* test not applicable */ return; + + // given + assertFalse(settingsStore().hasSettings()); + webappInstanceNameEnteredAs("some.instance"); + passwordEnteredAs("some-password"); + uncheckPollingEnabled(); + + // when + saveClicked(); + + // then + assertTrue(settingsStore().hasSettings()); + assertFalse(settings().pollingEnabled); + } + +//> TEST HELPERS + private void urlEnteredAs(String url) { + if(NOT_GENERIC_FLAVOUR) throw new IllegalStateException(); + + enterText(id.txtWebappUrl, url); + } + + private void webappInstanceNameEnteredAs(String instanceName) { + if(NOT_MEDIC_FLAVOUR) throw new IllegalStateException(); + + enterText(id.txtWebappInstanceName, instanceName); + } + + private void passwordEnteredAs(String password) { + if(NOT_MEDIC_FLAVOUR) throw new IllegalStateException(); + + enterText(id.txtWebappPassword, password); + } + + private void checkPollingEnabled() { + onView(allOf(withId(id.cbxEnablePolling), isChecked())); + } + + private void uncheckPollingEnabled() { + onView(allOf(withId(id.cbxEnablePolling), isChecked())) + .perform(click()); + } + + private void checkCdmaCompatEnabled() { + onView(allOf(withId(id.cbxEnableCdmaCompatMode), isNotChecked())) + .perform(click()); + } + + private void saveClicked() { + onView(withId(id.btnSaveSettings)) + .perform(click()); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/SimpleJsonClient2Test.java b/src/androidTest/java/medic/gateway/alert/SimpleJsonClient2Test.java new file mode 100644 index 0000000..df914f9 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/SimpleJsonClient2Test.java @@ -0,0 +1,44 @@ +package medic.gateway.alert; + +import android.test.*; + +import okhttp3.mockwebserver.*; + +import java.util.concurrent.TimeUnit; + +import org.junit.*; + +import static org.junit.Assert.*; + +@SuppressWarnings("PMD.SignatureDeclareThrowsException") +public class SimpleJsonClient2Test { + private MockWebServer server; + +//> TEST SETUP/TEARDOWN + @Before + public void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + // server hangs without a response queued: + server.enqueue(new MockResponse()); + } + + @After + public void tearDown() throws Exception { + server.shutdown(); + } + + @Test + public void test_basicAuth_simplePassword() throws Exception { + // given + String url = String.format("http://uname:pword@localhost:%s/some-path", server.getPort()); + + // when + new SimpleJsonClient2().get(url); + + // then + RecordedRequest r = server.takeRequest(1, TimeUnit.MILLISECONDS); + assertEquals("GET /some-path HTTP/1.1", r.getRequestLine()); + assertEquals("Basic dW5hbWU6cHdvcmQ=", r.getHeader("Authorization")); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/WakefulServiceTest.java b/src/androidTest/java/medic/gateway/alert/WakefulServiceTest.java new file mode 100644 index 0000000..ef5fd87 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/WakefulServiceTest.java @@ -0,0 +1,206 @@ +package medic.gateway.alert; + +import android.test.AndroidTestCase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import android.content.Intent; +import com.commonsware.cwac.wakeful.WakefulIntentService; + +import medic.gateway.alert.test.DbTestHelper; +import medic.gateway.alert.test.HttpTestHelper; +import okhttp3.mockwebserver.RecordedRequest; + +import static medic.gateway.alert.test.DbTestHelper.cols; +import static medic.gateway.alert.test.DbTestHelper.vals; +import static medic.gateway.alert.test.TestUtils.ANY_NUMBER; +import static medic.gateway.alert.test.TestUtils.A_PHONE_NUMBER; +import static medic.gateway.alert.test.TestUtils.SOME_CONTENT; + +@SuppressWarnings({"PMD.SignatureDeclareThrowsException", "PMD.JUnitTestsShouldIncludeAssert"}) +public class WakefulServiceTest extends AndroidTestCase { + private DbTestHelper db; + private HttpTestHelper http; + @Before + public void setUp() throws Exception { + super.setUp(); + + db = new DbTestHelper(getContext()); + http = new HttpTestHelper(); + http.configureAppSettings(getContext()); + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + + http.tearDown(); + db.tearDown(); + + http.assertNoMoreRequests(); + } + + @Test + public void test_doWakefulWork_unsentMessagesShouldSendMessages() throws Exception { + // given + db.insert("wt_message", + cols("_id", "status", "last_action", "_from", "content", "sms_sent", "sms_received"), + vals("message-0001", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-0002", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-0003", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0)); + http.nextResponseJson("{}"); + + // when + Intent i = new Intent(getContext(), WakefulIntentService.class); + WakefulService wfs = new WakefulService(getContext()); + wfs.doWakefulWork(i); + + //then + db.assertTable("wt_message", + "message-0001", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-0002", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-0003", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER); + + RecordedRequest request = http.server.takeRequest(); + assertEquals("{\"messages\":[" + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-0001\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-0002\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-0003\"}" + + "],\"updates\":[]}", request.getBody().readUtf8()); + } + + @Test + public void test_doWakefulWork_unsentMessagesShouldSendMultipleBatches() throws Exception { + // given + db.insert("wt_message", + cols("_id", "status", "last_action", "_from", "content", "sms_sent", "sms_received"), + vals("message-1001", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1002", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1003", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1004", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1005", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1006", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1007", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1008", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1009", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1010", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1011", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1012", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1013", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1014", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1015", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1016", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0)); + http.nextResponseJson("{}"); + http.nextResponseJson("{}"); + + // when + Intent i = new Intent(getContext(), WakefulIntentService.class); + WakefulService wfs = new WakefulService(getContext()); + wfs.doWakefulWork(i); + + //then + db.assertTable("wt_message", + "message-1001", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1002", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1003", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1004", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1005", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1006", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1007", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1008", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1009", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1010", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1011", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1012", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1013", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1014", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1015", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER, + "message-1016", "FORWARDED", ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, ANY_NUMBER, ANY_NUMBER); + + RecordedRequest firstRequest = http.server.takeRequest(); + assertEquals("{\"messages\":[" + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1001\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1002\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1003\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1004\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1005\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1006\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1007\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1008\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1009\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1010\"}" + + "],\"updates\":[]}", firstRequest.getBody().readUtf8()); + + RecordedRequest secondRequest = http.server.takeRequest(); + assertEquals("{\"messages\":[" + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1011\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1012\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1013\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1014\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1015\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1016\"}" + + "],\"updates\":[]}", secondRequest.getBody().readUtf8()); + } + + @Test + public void test_doWakefulWork_shouldNotSendNextRequestIfFailed() throws Exception { + // given + db.insert("wt_message", + cols("_id", "status", "last_action", "_from", "content", "sms_sent", "sms_received"), + vals("message-1001", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1002", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1003", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1004", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1005", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1006", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1007", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1008", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1009", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1010", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1011", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1012", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals("message-1013", WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0)); + http.nextResponseError(500); + + + // when + Intent i = new Intent(getContext(), WakefulIntentService.class); + WakefulService wfs = new WakefulService(getContext()); + wfs.doWakefulWork(i); + + + //then + db.assertTable("wt_message", + "message-1001", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1002", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1003", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1004", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1005", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1006", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1007", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1008", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1009", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1010", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1011", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1012", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0, + "message-1013", WtMessage.Status.WAITING, ANY_NUMBER, A_PHONE_NUMBER, SOME_CONTENT, 0, 0); + + int requestCount = http.server.getRequestCount(); + assertEquals(1, requestCount); + + RecordedRequest request = http.server.takeRequest(); + assertEquals("{\"messages\":[" + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1001\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1002\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1003\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1004\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1005\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1006\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1007\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1008\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1009\"}," + + "{\"sms_received\":0,\"sms_sent\":0,\"content\":\"Hello.\",\"from\":\"+447890123123\",\"id\":\"message-1010\"}" + + "],\"updates\":[]}", request.getBody().readUtf8()); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/WebappPollerTest.java b/src/androidTest/java/medic/gateway/alert/WebappPollerTest.java new file mode 100644 index 0000000..85eb063 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/WebappPollerTest.java @@ -0,0 +1,307 @@ +package medic.gateway.alert; + +import android.test.*; + +import medic.gateway.alert.test.*; + +import okhttp3.mockwebserver.*; + +import org.json.*; +import org.junit.*; + +import static org.junit.Assert.*; +import static medic.gateway.alert.test.DbTestHelper.*; +import static medic.gateway.alert.test.TestUtils.*; + +@SuppressWarnings({"PMD.SignatureDeclareThrowsException", "PMD.JUnitTestsShouldIncludeAssert"}) +public class WebappPollerTest extends AndroidTestCase { + private static final String NO_REASON = null; + + private DbTestHelper db; + private HttpTestHelper http; + + @Before + public void setUp() throws Exception { + super.setUp(); + + db = new DbTestHelper(getContext()); + + http = new HttpTestHelper(); + http.configureAppSettings(getContext()); + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + + http.tearDown(); + db.tearDown(); + + http.assertNoMoreRequests(); + } + +//> REQUEST CONTENT TESTS + @Test + public void test_pollWebapp_shouldOnlyIncludeWaitingMessagesInRequest() throws Exception { + // given + String waitingId = randomUuid(); + db.insert("wt_message", + cols("_id", "status", "last_action", "_from", "content", "sms_sent", "sms_received"), + vals(randomUuid(), WtMessage.Status.FORWARDED, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0), + vals(waitingId, WtMessage.Status.WAITING, 0, A_PHONE_NUMBER, SOME_CONTENT, 1, 2), + vals(randomUuid(), WtMessage.Status.FAILED, 0, A_PHONE_NUMBER, SOME_CONTENT, 0, 0)); + http.nextResponseJson("{}"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + JSONObject requestBody = http.assertPostRequestMade_withJsonResponse(); + + JSONArray messages = requestBody.getJSONArray("messages"); + assertEquals(1, messages.length()); + + JSONObject message = (JSONObject) messages.get(0); + assertEquals(waitingId, message.getString("id")); + assertEquals(A_PHONE_NUMBER, message.getString("from")); + assertEquals(SOME_CONTENT, message.getString("content")); + assertEquals(1, message.getLong("sms_sent")); + assertEquals(2, message.getLong("sms_received")); + } + + @Test + public void test_pollWebapp_shouldOnlyIncludeStatusUpdatesThatNeedForwarding() throws Exception { + // given + String deliveredId = randomUuid(); + String irrelevantId = randomUuid(); + db.insert("wo_message", + cols("_id", "status", "last_action", "_to", "content", "retries"), + vals(deliveredId, WoMessage.Status.DELIVERED, 0, A_PHONE_NUMBER, SOME_CONTENT, 0), + vals(irrelevantId, WoMessage.Status.DELIVERED, 0, A_PHONE_NUMBER, SOME_CONTENT, 0)); + db.insert("wom_status", + cols("message_id", "status", "timestamp", "needs_forwarding"), + vals(deliveredId, WoMessage.Status.DELIVERED, 0, true), + vals(irrelevantId, WoMessage.Status.DELIVERED, 0, false)); + http.nextResponseJson("{}"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + JSONObject requestBody = http.assertPostRequestMade_withJsonResponse(); + + JSONArray updates = requestBody.getJSONArray("updates"); + assertEquals(1, updates.length()); + + JSONObject update = (JSONObject) updates.get(0); + assertEquals(deliveredId, update.getString("id")); + assertEquals("DELIVERED", update.getString("status")); + } + + @Test + public void test_pollWebapp_shouldMarkForwardedStatusesAsSuchInDb() throws Exception { + // given + String messageId = randomUuid(); + db.insert("wo_message", + cols("_id", "status", "last_action", "_to", "content", "retries"), + vals(messageId, WoMessage.Status.DELIVERED, 0, A_PHONE_NUMBER, SOME_CONTENT, 0)); + db.insert("wom_status", + cols("message_id", "status", "timestamp", "needs_forwarding"), + vals(messageId, WoMessage.Status.DELIVERED, 0, true)); + http.nextResponseJson("{}"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertPostRequestMade_withJsonResponse(); + // and + db.assertTable("wo_message", + ANY_ID, "DELIVERED", NO_REASON, 0, A_PHONE_NUMBER, SOME_CONTENT, 0); + db.assertTable("wom_status", + ANY_NUMBER, messageId, "DELIVERED", NO_REASON, 0, false); + } + + @Test + public void test_pollWebapp_shouldIncludeReasonForFailedDeliveries() throws Exception { + // given + String messageId = randomUuid(); + db.insert("wo_message", + cols("_id", "status", "failure_reason", "last_action", "_to", "content", "retries"), + vals(messageId, WoMessage.Status.FAILED, "something-awful", 0, A_PHONE_NUMBER, SOME_CONTENT, 0)); + db.insert("wom_status", + cols("message_id", "status", "failure_reason", "timestamp", "needs_forwarding"), + vals(messageId, WoMessage.Status.FAILED, "something-awful", 0, true)); + http.nextResponseJson("{}"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + JSONObject response = http.assertPostRequestMade_withJsonResponse(); + JSONArray statusUpdates = response.getJSONArray("updates"); + assertEquals(1, statusUpdates.length()); + + // and + JSONObject update = statusUpdates.getJSONObject(0); + assertEquals(messageId, update.getString("id")); + assertEquals("FAILED", update.getString("status")); + assertEquals("something-awful", update.getString("reason")); + } + +//> RESPONSE CONTENT TESTS + @Test + public void test_pollWebapp_shouldFailQuietlyIfResponseIsError() throws Exception { + // given + http.nextResponseError(500); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + } + + @Test + public void test_pollWebapp_shouldFailQuietlyIfResponseIsNotJson() throws Exception { + // given + http.nextResponseJson("muhahaha not really json! {}"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + } + + @Test + public void test_pollWebapp_shouldBeFineIfMessagesIsNotIncludedInResponse() throws Exception { + // given + http.nextResponseJson("{}"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + } + + @Test + public void test_pollWebapp_shouldBeFineIfMessagesIsNull() throws Exception { + // given + http.nextResponseJson("{ \"messages\":null }"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + } + + @Test + public void test_pollWebapp_shouldBeFineIfNoMessagesInResponse() throws Exception { + // given + http.nextResponseJson("{ \"messages\":[] }"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + } + + @Test + public void test_pollWebapp_shouldSaveMessagesFromResponseToDb() throws Exception { + // given + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + http.nextResponseJson("{ \"messages\": [ " + + "{ \"id\": \"aaa-111\", \"to\": \"+1\", \"content\": \"testing: one\" }," + + "{ \"id\": \"aaa-222\", \"to\": \"+2\", \"content\": \"testing: two\" }" + + "] }"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertTable("wo_message", + "aaa-111", "UNSENT", NO_REASON, ANY_NUMBER, "+1", "testing: one", 0, + "aaa-222", "UNSENT", NO_REASON, ANY_NUMBER, "+2", "testing: two", 0); + db.assertTable("wom_status", + ANY_NUMBER, "aaa-111", "UNSENT", NO_REASON, ANY_NUMBER, true, + ANY_NUMBER, "aaa-222", "UNSENT", NO_REASON, ANY_NUMBER, true); + } + + @Test + public void test_pollWebapp_shouldStripSpecialCharactersInPhoneNumbersBeforeSavingToDb() throws Exception { + // given + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + http.nextResponseJson("{ \"messages\": [ " + + "{ \"id\": \"abc-123\", \"to\": \"+1-2 3\", \"content\": \"testing: abc\" }" + + "] }"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertTable("wo_message", + "abc-123", "UNSENT", NO_REASON, ANY_NUMBER, "+123", "testing: abc", 0); + db.assertTable("wom_status", + ANY_NUMBER, "abc-123", "UNSENT", NO_REASON, ANY_NUMBER, true); + } + + @Test + public void test_pollWebapp_poorlyFormedWoMessagesShouldNotAffectWellFormed() throws Exception { + // given + db.assertEmpty("wo_message"); + db.assertEmpty("wom_status"); + http.nextResponseJson("{ \"messages\": [ " + + "{ \"id\": \"ok-111\", \"to\": \"+1\", \"content\": \"ok: one\" }," + + + // no id + "{ \"to\": \"+1\", \"content\": \"bad\" }," + + + // no to + "{ \"id\": \"bad-111\", \"content\": \"bad\" }," + + + // no content + "{ \"id\": \"bad-222\", \"to\": \"+1\" }," + + + // no id or to + "{ \"content\": \"bad\" }," + + + // no id or content + "{ \"to\": \"+1\" }," + + + // no content or to + "{ \"id\": \"bad-333\" }," + + + "{ \"id\": \"ok-222\", \"to\": \"+2\", \"content\": \"ok: two\" }" + + "] }"); + + // when + new WebappPoller(getContext()).pollWebapp(); + + // then + http.assertSinglePostRequestMade(); + db.assertTable("wo_message", + "ok-111", "UNSENT", NO_REASON, ANY_NUMBER, "+1", "ok: one", 0, + "ok-222", "UNSENT", NO_REASON, ANY_NUMBER, "+2", "ok: two", 0); + db.assertTable("wom_status", + ANY_NUMBER, "ok-111", "UNSENT", NO_REASON, ANY_NUMBER, true, + ANY_NUMBER, "ok-222", "UNSENT", NO_REASON, ANY_NUMBER, true); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/WebappUrlVerifierTest.java b/src/androidTest/java/medic/gateway/alert/WebappUrlVerifierTest.java new file mode 100644 index 0000000..e5c1753 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/WebappUrlVerifierTest.java @@ -0,0 +1,130 @@ +package medic.gateway.alert; + +import android.test.*; + +import medic.gateway.alert.test.*; + +import okhttp3.mockwebserver.*; + +import org.junit.*; + +import static org.junit.Assert.*; +import static medic.gateway.alert.test.TestUtils.*; + +@SuppressWarnings("PMD.SignatureDeclareThrowsException") +public class WebappUrlVerifierTest extends AndroidTestCase { + private WebappUrlVerifier verifier; + + private HttpTestHelper http; + + @Before + public void setUp() throws Exception { + super.setUp(); + + http = new HttpTestHelper(); + http.configureAppSettings(getContext()); + + verifier = new WebappUrlVerifier(getContext()); + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + http.tearDown(); + } + + @Test + public void test_verify_shouldReturnInvalidUrlFailureIfUrlNotValid() { + // given + String badUrl = "not-a-real-url"; + + // when + WebappUrlVerififcation v = verifier.verify(badUrl); + + // then + assertEquals(badUrl, v.webappUrl); + assertFalse(v.isOk); + assertEquals(R.string.errInvalidUrl, v.failure); + } + + @Test + public void test_verify_shouldReturnOkResponseIfCorrectJsonReturned() { + // given + http.nextResponseJson("{\"medic-gateway\":true}"); + + // when + WebappUrlVerififcation v = verifier.verify(http.url()); + + // then + assertEquals(http.url(), v.webappUrl); + assertTrue(v.isOk); + + // and + http.assertSingleGetRequestMade(); + } + + @Test + public void test_verify_shouldReturnAppNotFoundFailureIfWrongJsonReturned() { + // given + http.nextResponseJson("{}"); + + // when + WebappUrlVerififcation v = verifier.verify(http.url()); + + // then + assertEquals(http.url(), v.webappUrl); + assertFalse(v.isOk); + assertEquals(R.string.errWebappUrl_appNotFound, v.failure); + + // and + http.assertSingleGetRequestMade(); + } + + @Test + public void test_verify_shouldReturnUnauthorisedFailureIfServerReturnsAuthError() { + // given + http.nextResponseError(401); + + // when + WebappUrlVerififcation v = verifier.verify(http.url()); + + // then + assertEquals(http.url(), v.webappUrl); + assertFalse(v.isOk); + assertEquals(R.string.errWebappUrl_unauthorised, v.failure); + + // and + http.assertSingleGetRequestMade(); + } + + @Test + public void test_verify_shouldReturnServerNotFoundFailureIfServerIsNotUp() throws Exception { + // given + http.server.shutdown(); + + // when + WebappUrlVerififcation v = verifier.verify(http.url()); + + // then + assertEquals(http.url(), v.webappUrl); + assertFalse(v.isOk); + assertEquals(R.string.errWebappUrl_serverNotFound, v.failure); + } + + @Test + public void test_verify_shouldSendAuthorisaztionHeaderIfIncludedInUrl() throws Exception { + // given + http.nextResponseJson("{\"medic-gateway\":true}"); + String urlWithAuth = String.format("http://username:password@%s:%s/api", + http.server.getHostName(), http.server.getPort()); + + // when + verifier.verify(urlWithAuth); + + // then + RecordedRequest r = http.assertSingleGetRequestMade(); + String authHeader = r.getHeader("Authorization"); + assertEquals("Basic ", authHeader.substring(0, 6)); + assertEquals("username:password", decodeBase64(authHeader.substring(6))); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/test/InstrumentationTestUtils.java b/src/androidTest/java/medic/gateway/alert/test/InstrumentationTestUtils.java new file mode 100644 index 0000000..88adf57 --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/test/InstrumentationTestUtils.java @@ -0,0 +1,157 @@ +package medic.gateway.alert.test; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.View; +import android.widget.TextView; +import androidx.lifecycle.Lifecycle; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.ViewAssertion; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import java.util.Iterator; +import medic.gateway.alert.*; +import org.json.*; +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.scrollTo; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isNotChecked; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.platform.app.InstrumentationRegistry.*; +import static medic.gateway.alert.R.*; +import static medic.gateway.alert.test.BuildConfig.IS_MEDIC_FLAVOUR; +import static org.junit.Assert.*; + +@SuppressWarnings("PMD") +public final class InstrumentationTestUtils { + private InstrumentationTestUtils() {} + + public static void clearAppSettings() { + SharedPreferences prefs = getInstrumentation().getTargetContext().getSharedPreferences( + SettingsStore.class.getName(), + Context.MODE_PRIVATE); + SharedPreferences.Editor ed = prefs.edit(); + ed.clear(); + assertTrue(ed.commit()); + } + + public static void recreateActivityFor(final ActivityScenarioRule testRule) { + testRule.getScenario().moveToState(Lifecycle.State.RESUMED).recreate(); + } + + public static void assertJson(String expected, String actual) throws JSONException { + assertJson(new JSONObject(expected), actual); + } + + public static void assertJson(JSONObject expected, String actual) throws JSONException { + assertJson(expected, new JSONObject(actual)); + } + + public static void assertJson(JSONObject expected, JSONObject actual) throws JSONException { + if(!areEqual(expected, actual)) assertEquals(expected.toString(), actual.toString()); + } + + public static void assertJson(String message, String expected, String actual) throws JSONException { + assertJson(message, new JSONObject(expected), actual); + } + + public static void assertJson(String message, JSONObject expected, String actual) throws JSONException { + assertJson(message, expected, new JSONObject(actual)); + } + + public static void assertJson(String message, JSONObject expected, JSONObject actual) throws JSONException { + if(!areEqual(expected, actual)) assertEquals(message, expected.toString(), actual.toString()); + } + + private static boolean areEqual(JSONObject a, JSONObject b) throws JSONException { + if(a.equals(b) || a.toString().equals(b.toString())) return true; + + Iterator keys = a.keys(); + while(keys.hasNext()) { + String k = keys.next(); + + if(!b.has(k)) return false; + + if(!areJsonValuesEqual(a.get(k), b.get(k))) return false; + } + + return true; + } + + @SuppressWarnings({"PMD.NPathComplexity", "PMD.ModifiedCyclomaticComplexity", "PMD.StdCyclomaticComplexity"}) + private static boolean areJsonValuesEqual(Object a, Object b) throws JSONException { + if(a instanceof Boolean && b instanceof Boolean) return ((boolean) a) == ((boolean) b); + if(a instanceof Double && b instanceof Double) return ((double) a) == ((double) b); + if(a instanceof Integer && b instanceof Integer) return ((int) a) == ((int) b); + if(a instanceof Long && b instanceof Long) return ((long) a) == ((long) b); + if(a instanceof String && b instanceof String) return ((String) a).equals(b); + if(a instanceof JSONObject && b instanceof JSONObject) return areEqual((JSONObject) a, (JSONObject) b); + if(a instanceof JSONArray && b instanceof JSONArray) { + JSONArray aa = (JSONArray) a; + JSONArray bb = (JSONArray) b; + + if(aa.length() != bb.length()) return false; + for(int i=aa.length()-1; i>=0; --i) + if(!areJsonValuesEqual(aa.get(i), bb.get(i))) + return false; + return true; + } + return false; + } + + public static void assertNotChecked(int cbxId) { + onView(withId(cbxId)).check(matches(isNotChecked())); + } + + public static void assertErrorDisplayed(int errorMessageResourceId) { + int componentId = IS_MEDIC_FLAVOUR ? id.txtWebappInstanceName : id.txtWebappUrl; + assertErrorDisplayed(componentId, errorMessageResourceId); + } + + public static void assertErrorDisplayed(int componentId, int errorMessageResourceId) { + String errorString = getApplicationContext().getResources().getString(errorMessageResourceId); + assertErrorDisplayed(componentId, errorString); + } + + public static void assertErrorDisplayed(int componentId, final String expectedMessage) { + onView(withId(componentId)) + .check(new ViewAssertion() { + public void check(View view, NoMatchingViewException noViewFoundException) { + if(!(view instanceof TextView)) + fail("Supplied view is not a TextView, so does not have an error property."); + TextView tv = (TextView) view; + assertEquals(expectedMessage, tv.getError()); + } + }); + } + + public static void assertVisible(int viewId) { + onView(withId(viewId)).perform(scrollTo()).check(matches(isDisplayed())); + } + + @SuppressWarnings("PMD.EmptyCatchBlock") + public static void assertDoesNotExist(int viewId) { + try { + onView(withId(viewId)).check(matches(isDisplayed())); + fail("Found view which should not exist!"); + } catch(NoMatchingViewException ex) { + // expected + } + } + + public static Settings settings() { + return Settings.in(getApplicationContext()); + } + + public static SettingsStore settingsStore() { + return SettingsStore.in(getApplicationContext()); + } + + public static void enterText(int componentId, String text) { + onView(withId(componentId)) + .perform(typeText(text), closeSoftKeyboard()); + } +} diff --git a/src/androidTest/java/medic/gateway/alert/test/WakingJUnitRunner.java b/src/androidTest/java/medic/gateway/alert/test/WakingJUnitRunner.java new file mode 100644 index 0000000..55420bf --- /dev/null +++ b/src/androidTest/java/medic/gateway/alert/test/WakingJUnitRunner.java @@ -0,0 +1,43 @@ +package medic.gateway.alert.test; + +import android.annotation.SuppressLint; +import android.app.KeyguardManager; +import android.content.Context; +import android.os.PowerManager; +import androidx.test.runner.AndroidJUnitRunner; + +import static android.content.Context.KEYGUARD_SERVICE; +import static android.content.Context.POWER_SERVICE; +import static android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP; +import static android.os.PowerManager.FULL_WAKE_LOCK; +import static android.os.PowerManager.ON_AFTER_RELEASE; + +/** + * The purpose of this test runner is to make sure that the android device/ + * emulator is awake and does not have screenlock enabled when the tests start. + * Otherwise, we may see the error: "Waited for the root of the view hierarchy + * to have window focus and not be requesting layout for over 10 seconds." + */ +public class WakingJUnitRunner extends AndroidJUnitRunner { + private PowerManager.WakeLock lock; + + @SuppressWarnings("deprecation") @SuppressLint("MissingPermission") + public void onStart() { + Context ctx = getTargetContext().getApplicationContext(); + + KeyguardManager k = (KeyguardManager) ctx.getSystemService(KEYGUARD_SERVICE); + k.newKeyguardLock(KEYGUARD_SERVICE).disableKeyguard(); + + PowerManager power = (PowerManager) ctx.getSystemService(POWER_SERVICE); + lock = power.newWakeLock(ACQUIRE_CAUSES_WAKEUP | FULL_WAKE_LOCK | ON_AFTER_RELEASE, getClass().getSimpleName()); + lock.acquire(); + + super.onStart(); + } + + public void onDestroy() { + super.onDestroy(); + + lock.release(); + } +} diff --git a/src/debug/AndroidManifest.xml b/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..10bc866 --- /dev/null +++ b/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/generic/res/values/strings.xml b/src/generic/res/values/strings.xml new file mode 100644 index 0000000..1f977df --- /dev/null +++ b/src/generic/res/values/strings.xml @@ -0,0 +1,4 @@ + + + SMS Gateway + diff --git a/src/libTest/java/medic/gateway/alert/test/DbTestHelper.java b/src/libTest/java/medic/gateway/alert/test/DbTestHelper.java new file mode 100644 index 0000000..7a0848f --- /dev/null +++ b/src/libTest/java/medic/gateway/alert/test/DbTestHelper.java @@ -0,0 +1,184 @@ +package medic.gateway.alert.test; + +import android.content.*; +import android.database.*; +import android.database.sqlite.*; + +import java.util.*; +import java.util.regex.*; +import java.lang.reflect.*; + +import medic.gateway.alert.*; + +import static java.util.UUID.randomUUID; +import static medic.gateway.alert.test.TestUtils.*; +import static org.junit.Assert.*; + +@SuppressWarnings({"PMD.JUnit4TestShouldUseAfterAnnotation", + "PMD.ModifiedCyclomaticComplexity", + "PMD.SignatureDeclareThrowsException", + "PMD.StdCyclomaticComplexity", + "PMD.UseVarargs"}) +public class DbTestHelper { + public static final String[] NO_ARGS = {}; + public static final String ALL_ROWS = null; + + private static final Random RANDOM = new Random(); + + private Db db; + public final SQLiteDatabase raw; + +//> CONSTRUCTORS + public DbTestHelper(SQLiteDatabase raw) { + this.raw = raw; + } + + public DbTestHelper(SQLiteOpenHelper sqliteOpenHelper) { + this.raw = sqliteOpenHelper.getWritableDatabase(); + } + + public DbTestHelper(Context ctx) throws Exception { + Constructor constructor = Db.class.getDeclaredConstructor(Context.class); + constructor.setAccessible(true); + db = (Db) constructor.newInstance(ctx); + raw = db.getWritableDatabase(); + } + +//> ACCESSORS + public Db getDb() { + if(db == null) throw new IllegalStateException("Should not be trying to get db unless the DbTestHelper was constructed using a Context"); + return db; + } + +//> TEST METHODS + public void tearDown() { + raw.delete("log", ALL_ROWS, NO_ARGS); + raw.delete("wt_message", ALL_ROWS, NO_ARGS); + raw.delete("wtm_status", ALL_ROWS, NO_ARGS); + raw.delete("wt_message_part", ALL_ROWS, NO_ARGS); + raw.delete("wo_message", ALL_ROWS, NO_ARGS); + raw.delete("wom_status", ALL_ROWS, NO_ARGS); + raw.close(); + try { + Field dbInstanceField = Db.class.getDeclaredField("_instance"); + dbInstanceField.setAccessible(true); + dbInstanceField.set(null, null); + } catch(Exception ex) { + throw new RuntimeException(ex); + } + } + + public long count(String tableName) { + return raw.compileStatement("SELECT COUNT(*) FROM " + tableName).simpleQueryForLong(); + } + + public Cursor selectById(String tableName, String[] cols, String id) { + Cursor c = raw.query(tableName, cols, "_id=?", args(id), null, null, null); + assertEquals(1, c.getCount()); + c.moveToFirst(); + return c; + } + + public void insert(String tableName, String[] cols, Object[]... valss) { + long initialCount = count(tableName); + for(Object[] vals : valss) { + ContentValues v = new ContentValues(); + for(int i=cols.length-1; i>=0; --i) { + if(vals[i] == null) v.put(cols[i], (String) null); + else if(vals[i] instanceof String) v.put(cols[i], (String) vals[i]); + else if(vals[i] instanceof Byte) v.put(cols[i], (Byte) vals[i]); + else if(vals[i] instanceof Short) v.put(cols[i], (Short) vals[i]); + else if(vals[i] instanceof Integer) v.put(cols[i], (Integer) vals[i]); + else if(vals[i] instanceof Long) v.put(cols[i], (Long) vals[i]); + else if(vals[i] instanceof Float) v.put(cols[i], (Float) vals[i]); + else if(vals[i] instanceof Double) v.put(cols[i], (Double) vals[i]); + else if(vals[i] instanceof Boolean) v.put(cols[i], (Boolean) vals[i]); + else if(vals[i] instanceof byte[]) v.put(cols[i], (byte[]) vals[i]); + else v.put(cols[i], vals[i].toString()); + } + long rowId = raw.insertOrThrow(tableName, null, v); + assertEquals(++initialCount, count(tableName)); + assertNotEquals(-1, rowId); + } + } + + public void assertTable(String tableName, Object... expectedValues) { + Cursor c = raw.rawQuery("SELECT * FROM " + tableName, NO_ARGS); + assertValues(c, expectedValues); + } + + public void assertValues(String tableName, String[] cols, Object... expectedValues) { + StringBuilder colBuilder = new StringBuilder(); + for(String col : cols) colBuilder.append(',').append(col); + Cursor c = raw.rawQuery("SELECT " + colBuilder.substring(1) + " FROM " + tableName, NO_ARGS); + + assertValues(c, expectedValues); + } + + public void assertCount(String tableName, int expectedCount) { + assertEquals(expectedCount, count(tableName)); + } + + public void assertEmpty(String tableName) { + assertCount(tableName, 0); + } + +//> STATIC HELPERS + @SuppressWarnings("PMD.UnusedPrivateMethod") // looks like a bug in PMD + private void assertValues(Cursor c, Object... expectedValues) { + try { + int colCount = c.getColumnCount(); + + if(expectedValues.length % colCount != 0) + throw new IllegalArgumentException("Wrong number of columns in expected values."); + int expectedRowCount = expectedValues.length / colCount; + + assertEquals("Wrong number of rows in db.", expectedRowCount, c.getCount()); + + for(int i=0; i TEST SETUP/TEARDOWN + public void tearDown() throws Exception { + server.shutdown(); + } + + public void configureAppSettings(Context ctx) { + SharedPreferences.Editor ed = ctx + .getSharedPreferences(SettingsStore.class.getName(), Context.MODE_PRIVATE) + .edit(); + ed.putString("app-url", url()); + assertTrue(ed.commit()); + } + +//> CONVENIENCE METHODS + public String url() { + return server.url("/api").toString(); + } + +//> TEST HELPERS + public void nextResponseJson(String jsonString) { + server.enqueue(new MockResponse().setBody(jsonString)); + } + + public void nextResponseError(int httpResponseCode) { + server.enqueue(new MockResponse().setResponseCode(httpResponseCode)); + } + + public RecordedRequest assertSingleGetRequestMade() { + RecordedRequest r = nextRequest(); + assertEquals("GET /api HTTP/1.1", r.getRequestLine()); + assertEquals("application/json", r.getHeader("Content-Type")); + assertNull(nextRequest()); + return r; + } + + public JSONObject assertPostRequestMade_withJsonResponse() throws JSONException { + return new JSONObject(assertPostRequestMade().getBody().readUtf8()); + } + + public RecordedRequest assertPostRequestMade() { + RecordedRequest r = nextRequest(); + assertEquals("POST /api HTTP/1.1", r.getRequestLine()); + assertEquals("application/json", r.getHeader("Content-Type")); + return r; + } + + public void assertSinglePostRequestMade() { + assertPostRequestMade(); + assertNoMoreRequests(); + } + + public void assertNoMoreRequests() { + assertNull(nextRequest()); + } + + public RecordedRequest nextRequest() { + try { + return server.takeRequest(1, TimeUnit.MILLISECONDS); + } catch(InterruptedException ex) { + return null; + } + } +} diff --git a/src/libTest/java/medic/gateway/alert/test/TestUtils.java b/src/libTest/java/medic/gateway/alert/test/TestUtils.java new file mode 100644 index 0000000..fa3f61d --- /dev/null +++ b/src/libTest/java/medic/gateway/alert/test/TestUtils.java @@ -0,0 +1,99 @@ +package medic.gateway.alert.test; + +import android.annotation.*; +import android.app.*; +import android.content.*; +import android.util.Base64; +import android.view.*; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import medic.gateway.alert.*; + +import java.io.*; +import java.util.Random; +import java.util.List; +import java.util.regex.*; + +import static org.junit.Assert.*; + +/** + * There is a chance that the PDUs listed in this class are invalid on CDMA + * devices. This has the potential to break tests run on those devices. + */ +@SuppressFBWarnings("MS_MUTABLE_ARRAY") +public final class TestUtils { + public static final String A_PHONE_NUMBER = "+447890123123"; + public static final String ANOTHER_PHONE_NUMBER = "+447890000000"; + public static final Pattern ANY_PHONE_NUMBER = Pattern.compile("\\+\\d{7,12}"); + public static final String SOME_CONTENT = "Hello."; + public static final Pattern ANY_CONTENT = Pattern.compile(".*"); + public static final Pattern ANY_NUMBER = Pattern.compile("\\d+"); + public static final Pattern GT_ZERO = Pattern.compile("[1-9]+\\d*"); + public static final Pattern ANY_ID = Pattern.compile("[a-f0-9-]+"); + + public static final byte[] A_VALID_GSM_PDU = { + (byte) 0x07, (byte) 0x91, (byte) 0x44, (byte) 0x77, (byte) 0x28, (byte) 0x00, (byte) 0x80, (byte) 0x00, (byte) 0x04, (byte) 0x0c, (byte) 0x91, (byte) 0x44, (byte) 0x87, (byte) 0x09, (byte) 0x21, (byte) 0x43, (byte) 0x65, (byte) 0x00, (byte) 0x00, (byte) 0x90, (byte) 0x20, (byte) 0x11, (byte) 0x31, (byte) 0x74, (byte) 0x63, (byte) 0x00, (byte) 0x23, (byte) 0xc7, (byte) 0xf7, (byte) 0x9b, (byte) 0x0c, (byte) 0x32, (byte) 0xbf, (byte) 0xe5, (byte) 0xa0, (byte) 0xfc, (byte) 0xbb, (byte) 0xee, (byte) 0x02, (byte) 0x4d, (byte) 0xd9, (byte) 0x61, (byte) 0x38, (byte) 0xe8, (byte) 0xed, (byte) 0x06, (byte) 0xd1, (byte) 0xd1, (byte) 0x65, (byte) 0x90, (byte) 0x38, (byte) 0x3c, (byte) 0x5e, (byte) 0x83, (byte) 0xca, (byte) 0xf4, (byte) 0xb1, (byte) 0x0b, + }; + + public static final byte[] A_VALID_GSM_PDU_FROM_THE_MULTIPART_SENDER = { + (byte) 0x07, (byte) 0x91, (byte) 0x44, (byte) 0x77, (byte) 0x28, (byte) 0x00, (byte) 0x80, (byte) 0x00, (byte) 0x04, (byte) 0x0c, (byte) 0x91, (byte) 0x44, (byte) 0x87, (byte) 0x09, (byte) 0x99, (byte) 0x99, (byte) 0x99, (byte) 0x00, (byte) 0x00, (byte) 0x90, (byte) 0x20, (byte) 0x11, (byte) 0x31, (byte) 0x74, (byte) 0x63, (byte) 0x00, (byte) 0x23, (byte) 0xc7, (byte) 0xf7, (byte) 0x9b, (byte) 0x0c, (byte) 0x32, (byte) 0xbf, (byte) 0xe5, (byte) 0xa0, (byte) 0xfc, (byte) 0xbb, (byte) 0xee, (byte) 0x02, (byte) 0x4d, (byte) 0xd9, (byte) 0x61, (byte) 0x38, (byte) 0xe8, (byte) 0xed, (byte) 0x06, (byte) 0xd1, (byte) 0xd1, (byte) 0x65, (byte) 0x90, (byte) 0x38, (byte) 0x3c, (byte) 0x5e, (byte) 0x83, (byte) 0xca, (byte) 0xf4, (byte) 0xb1, (byte) 0x0b, + }; + + public static final byte[] A_VALID_MULTIPART_GSM_PDU__PART_1 = { + (byte) 0x07, (byte) 0x91, (byte) 0x44, (byte) 0x97, (byte) 0x37, (byte) 0x01, (byte) 0x90, (byte) 0x37, (byte) 0x64, (byte) 0x0C, (byte) 0x91, (byte) 0x44, (byte) 0x87, (byte) 0x09, (byte) 0x99, (byte) 0x99, (byte) 0x99, (byte) 0x00, (byte) 0x00, (byte) 0x90, (byte) 0x20, (byte) 0x11, (byte) 0x71, (byte) 0x85, (byte) 0x31, (byte) 0x00, (byte) 0xA0, (byte) 0x06, (byte) 0x08, (byte) 0x04, (byte) 0xB9, (byte) 0xDB, (byte) 0x02, (byte) 0x01, (byte) 0xC8, (byte) 0xB2, (byte) 0xBC, (byte) 0x0C, (byte) 0x4A, (byte) 0xCF, (byte) 0x41, (byte) 0x61, (byte) 0x90, (byte) 0xBD, (byte) 0x2C, (byte) 0xCF, (byte) 0x83, (byte) 0xEC, (byte) 0x65, (byte) 0x79, (byte) 0x1E, (byte) 0x64, (byte) 0x2F, (byte) 0xCB, (byte) 0xF3, (byte) 0x20, (byte) 0x7B, (byte) 0x59, (byte) 0x9E, (byte) 0x07, (byte) 0xD9, (byte) 0xCB, (byte) 0xF2, (byte) 0x3C, (byte) 0xC8, (byte) 0x5E, (byte) 0x96, (byte) 0xE7, (byte) 0x41, (byte) 0xF6, (byte) 0xB2, (byte) 0x3C, (byte) 0x0F, (byte) 0xB2, (byte) 0x97, (byte) 0xE5, (byte) 0x79, (byte) 0x90, (byte) 0xBD, (byte) 0x2C, (byte) 0xCF, (byte) 0x83, (byte) 0xEC, (byte) 0x65, (byte) 0x79, (byte) 0x1E, (byte) 0x64, (byte) 0x2F, (byte) 0xCB, (byte) 0xF3, (byte) 0x20, (byte) 0x7B, (byte) 0x59, (byte) 0x9E, (byte) 0x07, (byte) 0xD9, (byte) 0xCB, (byte) 0xF2, (byte) 0x3C, (byte) 0xC8, (byte) 0x5E, (byte) 0x96, (byte) 0xE7, (byte) 0x41, (byte) 0xF6, (byte) 0xB2, (byte) 0x3C, (byte) 0x0F, (byte) 0xB2, (byte) 0x97, (byte) 0xE5, (byte) 0x79, (byte) 0x90, (byte) 0xBD, (byte) 0x2C, (byte) 0xCF, (byte) 0x83, (byte) 0xEC, (byte) 0x65, (byte) 0x79, (byte) 0x1E, (byte) 0xC4, (byte) 0x7E, (byte) 0xBB, (byte) 0xCF, (byte) 0xA0, (byte) 0x76, (byte) 0x79, (byte) 0x3E, (byte) 0x0F, (byte) 0x9F, (byte) 0xCB, (byte) 0xA0, (byte) 0x3B, (byte) 0x3A, (byte) 0x3D, (byte) 0x46, (byte) 0x83, (byte) 0xC2, (byte) 0x63, (byte) 0x7A, (byte) 0x3D, (byte) 0xCC, (byte) 0x66, (byte) 0xE7, (byte) 0x41, (byte) 0x73, (byte) 0x78, (byte) 0xD8, (byte) 0x3D, (byte) 0x07, (byte) 0xD1, (byte) 0xEF, (byte) 0x6F, (byte) 0xD0, (byte) 0x9B, (byte) 0x8E, (byte) 0x2E, (byte) 0xCB, (byte) 0x41, (byte) 0xED, (byte) 0xF2, (byte) 0x7C, (byte) 0x1E, (byte) 0x3E, (byte) 0x97, (byte) 0xE7, + }; + public static final byte[] A_VALID_MULTIPART_GSM_PDU__PART_2 = { + (byte) 0x07, (byte) 0x91, (byte) 0x44, (byte) 0x97, (byte) 0x37, (byte) 0x01, (byte) 0x90, (byte) 0x37, (byte) 0x64, (byte) 0x0C, (byte) 0x91, (byte) 0x44, (byte) 0x87, (byte) 0x09, (byte) 0x99, (byte) 0x99, (byte) 0x99, (byte) 0x00, (byte) 0x00, (byte) 0x90, (byte) 0x20, (byte) 0x11, (byte) 0x71, (byte) 0x85, (byte) 0x63, (byte) 0x00, (byte) 0x25, (byte) 0x06, (byte) 0x08, (byte) 0x04, (byte) 0xB9, (byte) 0xDB, (byte) 0x02, (byte) 0x02, (byte) 0xA0, (byte) 0x30, (byte) 0x3C, (byte) 0x2C, (byte) 0xA7, (byte) 0x83, (byte) 0xCC, (byte) 0xF2, (byte) 0x77, (byte) 0x1B, (byte) 0x44, (byte) 0x47, (byte) 0x97, (byte) 0x41, (byte) 0x6F, (byte) 0x79, (byte) 0xFA, (byte) 0x9C, (byte) 0x76, (byte) 0x87, (byte) 0xD9, (byte) 0xA0, (byte) 0xB7, (byte) 0xBB, (byte) 0x1C, (byte) 0x02, + }; + + public static final byte[] A_VALID_DELIVERED_REPORT = { + (byte) 0x07, (byte) 0x91, (byte) 0x52, (byte) 0x74, (byte) 0x22, (byte) 0x05, (byte) 0x00, (byte) 0x00, (byte) 0x06, (byte) 0x07, (byte) 0x0A, (byte) 0x81, (byte) 0x70, (byte) 0x20, (byte) 0x95, (byte) 0x77, (byte) 0x11, (byte) 0x11, (byte) 0x21, (byte) 0x12, (byte) 0x11, (byte) 0x33, (byte) 0x91, (byte) 0xE1, (byte) 0x11, (byte) 0x21, (byte) 0x12, (byte) 0x11, (byte) 0x33, (byte) 0x91, (byte) 0x21, (byte) 0x00, + }; + + private static final Random RANDOM = new Random(); + + private TestUtils() {} + + public static void assertMatches(Object pattern, Object actual) { + assertMatches(null, pattern, actual); + } + + public static void assertMatches(String failureMessage, Object pattern, Object actual) { + if(pattern == null) throw new IllegalArgumentException(); + if(actual == null) fail(String.format("%s\"null\" did not match regex /%s/", failureMessage, pattern)); + boolean matches = ((Pattern) pattern).matcher(actual.toString()).matches(); + if(!matches) { + if(failureMessage == null) { + failureMessage = ""; + } else { + failureMessage += ": "; + } + fail(String.format("%s\"%s\" did not match regex /%s/", failureMessage, actual, pattern)); + } + } + + public static void assertListEquals(List actual, T...expected) { + assertArrayEquals(expected, actual.toArray()); + } + + public static String decodeBase64(String encodedString) { + try { + return new String(Base64.decode(encodedString, Base64.DEFAULT), "UTF-8"); + } catch(UnsupportedEncodingException ex) { + throw new RuntimeException(ex); + } + } + + public static long now() { + return System.currentTimeMillis(); + } + + public static long daysAgo(long numberOfDaysAgo) { + return now() - (numberOfDaysAgo * 1000 * 60 * 60 * 24); + } + + public static int randomInt(int limit) { + return RANDOM.nextInt(limit); + } +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e9640ce --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/ic_launcher-playstore.png b/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..5b0f2c5 Binary files /dev/null and b/src/main/ic_launcher-playstore.png differ diff --git a/src/main/java/medic/android/ActivityBackgroundTask.java b/src/main/java/medic/android/ActivityBackgroundTask.java new file mode 100644 index 0000000..33db376 --- /dev/null +++ b/src/main/java/medic/android/ActivityBackgroundTask.java @@ -0,0 +1,46 @@ +package medic.android; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.os.AsyncTask; + +import java.lang.ref.WeakReference; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; + +public abstract class ActivityBackgroundTask extends AsyncTask { + private final WeakReference parent; + + public ActivityBackgroundTask(Parent parent) { + super(); + this.parent = new WeakReference<>(parent); + } + + /** + * @param caller the name of the calling class and method for use in logging and Throwables. + * @return the parent context of this task + * @throws IllegalStateException if no parent context was found + */ + protected Parent getRequiredCtx(String caller) { + Parent ctx = getCtx(); + + if(ctx == null) throw new IllegalStateException(String.format("%s :: couldn't get parent activity.", caller)); + + return ctx; + } + + /** + * @return the parent context of this task, or null if the task is finishing, is destroyed, or has been dereferenced. + */ + @SuppressLint("ObsoleteSdkInt") + protected Parent getCtx() { + Parent parent = this.parent.get(); + + if(parent == null) return null; + if(parent.isFinishing()) return null; + if(SDK_INT >= JELLY_BEAN_MR1 && parent.isDestroyed()) return null; + + else return parent; + } +} diff --git a/src/main/java/medic/gateway/alert/AlarmListener.java b/src/main/java/medic/gateway/alert/AlarmListener.java new file mode 100644 index 0000000..2d1f689 --- /dev/null +++ b/src/main/java/medic/gateway/alert/AlarmListener.java @@ -0,0 +1,45 @@ +package medic.gateway.alert; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.annotation.SuppressLint; + +import com.commonsware.cwac.wakeful.WakefulIntentService; + +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.Settings.POLL_INTERVAL; + +@SuppressLint("ShortAlarm") // On Android 5.1+, poll interval will be forced up to 60s if below that +public class AlarmListener implements WakefulIntentService.AlarmListener { + public void scheduleAlarms(AlarmManager am, PendingIntent pendingIntent, Context ctx) { + Settings s = Settings.in(ctx); + if(s != null && s.pollingEnabled) { + logEvent(ctx, "AlarmManager.scheduleAlarms() :: polling enabled - setting alarms"); + + // On SDK >= 19, setRepeating will be inexact - the OS will try to fit alarms in with other + // activity which wakes the device. This should be better for battery life, and -seems- + // acceptable. However, testing across a range of devices may prove that it is simpler + // to use setWindow(), and reschedule the alarm each time it fires. + am.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), POLL_INTERVAL, pendingIntent); + } else { + logEvent(ctx, "AlarmManager.scheduleAlarms() :: polling disabled - cancelling alarms"); + WakefulIntentService.cancelAlarms(ctx); + } + } + + public void sendWakefulWork(Context ctx) { + WakefulIntentService.sendWakefulWork(ctx, new Intent(ctx, WakefulService.class)); + } + + public long getMaxAge(Context ctx) { + return POLL_INTERVAL * 2L; + } + +//> PUBLIC STATIC + public static void restart(Context ctx) { + WakefulIntentService.cancelAlarms(ctx); + WakefulIntentService.scheduleAlarms(new AlarmListener(), ctx); + } +} diff --git a/src/main/java/medic/gateway/alert/Capabilities.java b/src/main/java/medic/gateway/alert/Capabilities.java new file mode 100644 index 0000000..0fcb74f --- /dev/null +++ b/src/main/java/medic/gateway/alert/Capabilities.java @@ -0,0 +1,41 @@ +package medic.gateway.alert; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.provider.Telephony; + +import static medic.gateway.alert.BuildConfig.APPLICATION_ID; +import static medic.gateway.alert.GatewayLog.logEvent; + +@SuppressWarnings("PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal") // Class isn't final, so it can be mocked in tests. +public class Capabilities { + + private Capabilities() {} + + @TargetApi(19) + public boolean isDefaultSmsProvider(Context ctx) { + if (!canBeDefaultSmsProvider()) { + throw new IllegalStateException("Gateway can not be the default SMS Provider. SDK: " + Build.VERSION.SDK_INT); + } + + String defaultSMSProvider = Telephony.Sms.getDefaultSmsPackage(ctx); + logEvent(ctx, "Default SMS Provider: %s", defaultSMSProvider); + + return APPLICATION_ID.equals(defaultSMSProvider); + } + + /** + * Check if cht-gateway can be the default messaging app on this device. + * This feature is only available on Android 4.4 (kitkat®) or later. + */ + @SuppressLint("ObsoleteSdkInt") // lint seems to think checking for > KITKAT is unnecessary: "Error: Unnecessary; SDK_INT is never < 16", but I think KITKAT is version 19 or 20 + public boolean canBeDefaultSmsProvider() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + } + + public static Capabilities getCapabilities() { + return new Capabilities(); + } +} diff --git a/src/main/java/medic/gateway/alert/ComposeSmsActivity.java b/src/main/java/medic/gateway/alert/ComposeSmsActivity.java new file mode 100644 index 0000000..be4c1b1 --- /dev/null +++ b/src/main/java/medic/gateway/alert/ComposeSmsActivity.java @@ -0,0 +1,82 @@ +package medic.gateway.alert; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import static medic.gateway.alert.Utils.randomUuid; +import static medic.gateway.alert.Utils.toast; + +public class ComposeSmsActivity extends Activity { + +//> EVENT HANDLERS + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.composer); + + String recipient = getSmsRecipient(getIntent()); + if(recipient != null) { + text(R.id.txtComposer_recipients, recipient); + } + } + +//> CUSTOM EVENT HANDLERS + public void send(View view) { + String[] recipients = getRecipients(); + + if(recipients.length == 0) { + showError(R.id.txtComposer_recipients, R.string.errComposer_noRecipients); + } else { + Db db = Db.getInstance(this); + + String content = text(R.id.txtComposer_content); + + for(String recipient : recipients) { + WoMessage m = new WoMessage(randomUuid(), recipient, content); + db.store(m); + } + + toast(this, getResources().getQuantityString(R.plurals.txtComposer_sentConfirmation, recipients.length, recipients.length)); + + finish(); + } + } + +//> PRIVATE HELPERS + private String getSmsRecipient(Intent i) { + Uri triggerUri = i.getData(); + if(triggerUri == null) return null; + String recipient = triggerUri.getSchemeSpecificPart(); + if(recipient == null) return null; + recipient = recipient.trim(); + if(recipient.length() == 0) return null; + return recipient.replaceAll(" ", ""); + } + + private String[] getRecipients() { + String userInput = text(R.id.txtComposer_recipients); + return userInput.replaceAll("[-\\s]", "") + .replaceAll("[,;:]+", ",") + .split(","); + } + + private String text(int componentId) { + EditText field = (EditText) findViewById(componentId); + return field.getText().toString(); + } + + private void text(int componentId, String value) { + EditText field = (EditText) findViewById(componentId); + field.setText(value); + } + + private void showError(int componentId, int stringId) { + TextView field = (TextView) findViewById(componentId); + field.setError(getString(stringId)); + } +} diff --git a/src/main/java/medic/gateway/alert/Db.java b/src/main/java/medic/gateway/alert/Db.java new file mode 100644 index 0000000..5c5e65c --- /dev/null +++ b/src/main/java/medic/gateway/alert/Db.java @@ -0,0 +1,971 @@ +package medic.gateway.alert; + +import android.content.ContentValues; +import android.content.Context; +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import android.database.Cursor; +import android.database.SQLException; +import android.telephony.SmsMessage; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.UUID.randomUUID; +import static medic.gateway.alert.BuildConfig.DEBUG; +import static medic.gateway.alert.BuildConfig.FORCE_SEED; +import static medic.gateway.alert.BuildConfig.LOAD_SEED_DATA; +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.GatewayLog.warnException; +import static medic.gateway.alert.Utils.args; +import static medic.gateway.alert.DebugUtils.randomPhoneNumber; +import static medic.gateway.alert.DebugUtils.randomSmsContent; + +@SuppressWarnings({"PMD.GodClass", "PMD.TooManyMethods"}) +public final class Db extends SQLiteOpenHelper { + private static final int SCHEMA_VERSION = 7; + + private static final String ALL = null, NO_GROUP = null; + private static final String[] NO_ARGS = {}; + private static final String NO_CRITERIA = null; + private static final String NO_LIMIT = null; + private static final String DEFAULT_SORT_ORDER = null; + + private static final String tblLOG = "log"; + private static final String LOG_clmID = "_id"; + private static final String LOG_clmTIMESTAMP = "timestamp"; + private static final String LOG_clmMESSAGE = "message"; + + private static final String tblWT_MESSAGE = "wt_message"; + private static final String WTM_clmID = "_id"; + private static final String WTM_clmSTATUS = "status"; + private static final String WTM_clmLAST_ACTION = "last_action"; + private static final String WTM_clmFROM = "_from"; + private static final String WTM_clmCONTENT = "content"; + private static final String WTM_clmSMS_SENT = "sms_sent"; + private static final String WTM_clmSMS_RECEIVED = "sms_received"; + + private static final String tblWT_MESSAGE_PART = "wt_message_part"; + private static final String WMP_clmFROM = "_from"; + private static final String WMP_clmCONTENT = "content"; + private static final String WMP_clmSENT = "sent"; + private static final String WMP_clmRECEIVED = "received"; + private static final String WMP_clmMP_REF = "mp_reference"; + private static final String WMP_clmMP_PART = "mp_part_number"; + private static final String WMP_clmMP_TOTAL_PARTS = "mp_total_parts"; + + private static final String tblWT_STATUS = "wtm_status"; + private static final String WTS_clmID = "_id"; + private static final String WTS_clmMESSAGE_ID = "message_id"; + private static final String WTS_clmSTATUS = "status"; + private static final String WTS_clmTIMESTAMP = "timestamp"; + + private static final String tblWO_MESSAGE = "wo_message"; + private static final String WOM_clmID = "_id"; + private static final String WOM_clmSTATUS = "status"; + private static final String WOM_clmSTATUS_NEEDS_FORWARDING = "status_needs_forwarding"; + private static final String WOM_clmFAILURE_REASON = "failure_reason"; + private static final String WOM_clmRETRIES = "retries"; + private static final String WOM_clmLAST_ACTION = "last_action"; + private static final String WOM_clmTO = "_to"; + private static final String WOM_clmCONTENT = "content"; + + private static final String tblWO_STATUS = "wom_status"; + private static final String WOS_clmID = "_id"; + private static final String WOS_clmMESSAGE_ID = "message_id"; + private static final String WOS_clmSTATUS = "status"; + private static final String WOS_clmFAILURE_REASON = "failure_reason"; + private static final String WOS_clmTIMESTAMP = "timestamp"; + private static final String WOS_clmNEEDS_FORWARDING = "needs_forwarding"; + private static final String[] WOS_SELECT_COLS = new String[] { + WOS_clmID, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmFAILURE_REASON, WOS_clmTIMESTAMP }; + + private static final String TRUE = "1"; + private static final String FALSE = "0"; + + private static Db _instance; + + private final Context ctx; + private final SQLiteDatabase db; // NOPMD + + private final ExternalLog external; + + /** a soft limit for the number of log entries to store in the system */ + private int logEntryLimit; + private String logEntryLimitString; + + public static synchronized Db getInstance(Context ctx) { // NOPMD + if(_instance == null) { + _instance = new Db(ctx); + if(LOAD_SEED_DATA && (FORCE_SEED || + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblLOG).simpleQueryForLong() == 0)) { + _instance.seed(); + } + + if(DEBUG) _instance.storeLogEntry("Log entries: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblLOG).simpleQueryForLong()); + if(DEBUG) _instance.storeLogEntry("WT messages: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWT_MESSAGE).simpleQueryForLong()); + if(DEBUG) _instance.storeLogEntry("WT message status updates: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWT_STATUS).simpleQueryForLong()); + if(DEBUG) _instance.storeLogEntry("WO messages: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWO_MESSAGE).simpleQueryForLong()); + if(DEBUG) _instance.storeLogEntry("WO message status updates: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWO_STATUS).simpleQueryForLong()); + } + + return _instance; + } + + private Db(Context ctx) { + super(ctx, "medic_gateway", null, SCHEMA_VERSION); + this.ctx = ctx; + db = getWritableDatabase(); + + external = ExternalLog.getInstance(ctx); + + setLogEntryLimit(200); + } + + public void onCreate(SQLiteDatabase db) { + db.execSQL(String.format("CREATE TABLE %s (" + + "%s INTEGER PRIMARY KEY, " + + "%s INTEGER NOT NULL, " + + "%s TEXT NOT NULL)", + tblLOG, LOG_clmID, LOG_clmTIMESTAMP, LOG_clmMESSAGE)); + + db.execSQL(String.format("CREATE TABLE %s (" + + "%s TEXT NOT NULL PRIMARY KEY, " + + "%s TEXT NOT NULL, " + + "%s INTEGER NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s INTEGER NOT NULL, " + + "%s INTEGER NOT NULL)", + tblWT_MESSAGE, WTM_clmID, WTM_clmSTATUS, WTM_clmLAST_ACTION, WTM_clmFROM, WTM_clmCONTENT, WTM_clmSMS_SENT, WTM_clmSMS_RECEIVED)); + + db.execSQL(String.format("CREATE TABLE %s (" + + "%s TEXT NOT NULL PRIMARY KEY, " + + "%s TEXT NOT NULL, " + + "%s TEXT, " + + "%s INTEGER NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s INTEGER NOT NULL DEFAULT(0))", + tblWO_MESSAGE, WOM_clmID, WOM_clmSTATUS, WOM_clmFAILURE_REASON, WOM_clmLAST_ACTION, WOM_clmTO, WOM_clmCONTENT, WOM_clmRETRIES)); + + migrate_createTable_WoMessageStatusUpdate(db, true); + migrate_createTable_WtMessageStatusUpdate(db, true); + migrate_createTable_WtMessagePart(db, true); + } + + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + + trace(this, "onUpgrade() :: oldVersion=%s, newVersion=%s", oldVersion, newVersion); + + if (oldVersion < 2) { + migrate_createTable_WoMessageStatusUpdate(db, false); + } + if (oldVersion < 3) { + migrate_create_WOS_clmNEEDS_FORWARDING(db); + } + if (oldVersion < 4) { + migrate_createTable_WtMessageStatusUpdate(db, false); + } + if (oldVersion < 5) { + migrate_create_WTM_clmSMS_SENT__clmSMS_RECEIVED(db); + } + if (oldVersion < 6) { + migrate_createTable_WtMessagePart(db, false); + } + if (oldVersion < 7) { + migrate_addRetriesColumn_WoMessage(db); + } + } + +//> MIGRATIONS + static void migrate_addRetriesColumn_WoMessage(SQLiteDatabase db) { + trace(db, "onUpgrade() :: migrate_addRetriesColumn_WoMessage()"); + + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)", + tblWO_MESSAGE, WOM_clmRETRIES)); + } + + static void migrate_createTable_WoMessageStatusUpdate(SQLiteDatabase db, boolean isCleanDb) { + trace(db, "onUpgrade() :: migrate_createTable_WoMessageStatusUpdate()"); + db.execSQL(String.format("CREATE TABLE %s (" + + "%s INTEGER PRIMARY KEY, " + + "%s TEXT NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s TEXT, " + + "%s INTEGER NOT NULL, " + + "%s INTEGER NOT NULL)", + tblWO_STATUS, WOS_clmID, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmFAILURE_REASON, WOS_clmTIMESTAMP, WOS_clmNEEDS_FORWARDING)); + + if(!isCleanDb) { + db.execSQL(String.format("INSERT INTO %s(%s, %s, %s, %s, %s) SELECT %s, %s, %s, %s, %s FROM %s", + tblWO_STATUS, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmFAILURE_REASON, WOS_clmTIMESTAMP, WOS_clmNEEDS_FORWARDING, + WOM_clmID, WOM_clmSTATUS, WOM_clmFAILURE_REASON, WOM_clmLAST_ACTION, WOM_clmSTATUS_NEEDS_FORWARDING, tblWO_MESSAGE)); + } + } + + static void migrate_createTable_WtMessageStatusUpdate(SQLiteDatabase db, boolean isCleanDb) { + trace(db, "onUpgrade() :: migrate_createTable_WtMessageStatusUpdate()"); + db.execSQL(String.format("CREATE TABLE %s (" + + "%s INTEGER PRIMARY KEY, " + + "%s TEXT NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s INTEGER NOT NULL)", + tblWT_STATUS, WTS_clmID, WTS_clmMESSAGE_ID, WTS_clmSTATUS, WTS_clmTIMESTAMP)); + + if(!isCleanDb) { + db.execSQL(String.format("INSERT INTO %s(%s, %s, %s) SELECT %s, %s, %s FROM %s", + tblWT_STATUS, WTS_clmMESSAGE_ID, WTS_clmSTATUS, WTS_clmTIMESTAMP, + WTM_clmID, WTM_clmSTATUS, WTM_clmLAST_ACTION, tblWT_MESSAGE)); + } + } + + static void migrate_create_WOS_clmNEEDS_FORWARDING(SQLiteDatabase db) { + trace(db, "onUpgrade() :: migrate_create_WOS_clmNEEDS_FORWARDING()"); + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)", + tblWO_STATUS, WOS_clmNEEDS_FORWARDING)); + // copy need_forwarding column values from wo_message into wom_status + rawUpdateOrDelete(db, "UPDATE %s SET %s=1 WHERE (%s || '_' || %s || '_' || %s) " + + "IN(SELECT (%s || '_' || %s || '_' || %s) FROM %s WHERE %s=1)", + cols(tblWO_STATUS, WOS_clmNEEDS_FORWARDING, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmTIMESTAMP, + WOM_clmID, WOM_clmSTATUS, WOM_clmLAST_ACTION, tblWO_MESSAGE, WOM_clmSTATUS_NEEDS_FORWARDING)); + + // We should now drop the status_needs_forwarding column from + // the wo_message table. However, dropping columns is not + // directly supported in SQLite. There seems little harm in + // leaving the column in place. + } + + static void migrate_create_WTM_clmSMS_SENT__clmSMS_RECEIVED(SQLiteDatabase db) { + trace(db, "onUpgrade() :: migrate_create_WTM_clmSMS_SENT__clmSMS_RECEIVED()"); + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)", + tblWT_MESSAGE, WTM_clmSMS_SENT)); + db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)", + tblWT_MESSAGE, WTM_clmSMS_RECEIVED)); + + // These values were not stored for old messages, so we can't + // set a meaningful value for these columns for old messages. + } + + static void migrate_createTable_WtMessagePart(SQLiteDatabase db, boolean isCleanDb) { + trace(db, "onUpgrade() :: migrate_createTable_WtMessagePart()"); + db.execSQL(String.format("CREATE TABLE %s (" + + "%s TEXT NOT NULL, " + + "%s TEXT NOT NULL, " + + "%s INTEGER NOT NULL, " + + "%s INTEGER NOT NULL, " + + "%s INTEGER NOT NULL, " + + "%s INTEGER NOT NULL, " + + "%s INTEGER NOT NULL, " + + "PRIMARY KEY (%s, %s, %s, %s))", + tblWT_MESSAGE_PART, + WMP_clmFROM, WMP_clmCONTENT, WMP_clmSENT, WMP_clmRECEIVED, WMP_clmMP_REF, WMP_clmMP_PART, WMP_clmMP_TOTAL_PARTS, + WMP_clmFROM, WMP_clmMP_REF, WMP_clmMP_PART, WMP_clmMP_TOTAL_PARTS)); + } + +//> ACCESSORS + void setLogEntryLimit(int limit) { + logEntryLimit = limit; + logEntryLimitString = Integer.toString(limit); + } + +//> GENERAL HANDLERS + int deleteOldData() { + long oneWeekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000); + + int totalRecordsDeleted = 0; + + totalRecordsDeleted += db.delete(tblLOG, lt(LOG_clmTIMESTAMP), args(oneWeekAgo)); + totalRecordsDeleted += db.delete(tblWO_MESSAGE, lt(WOM_clmLAST_ACTION), args(oneWeekAgo)); + totalRecordsDeleted += db.delete(tblWT_MESSAGE, lt(WTM_clmLAST_ACTION), args(oneWeekAgo)); + + // TODO do we need to VACUUM after deleting? + + return totalRecordsDeleted; + } + +//> GatewayEventLogEntry HANDLERS + void storeLogEntry(String message) { + ContentValues v = new ContentValues(); + v.put(LOG_clmTIMESTAMP, System.currentTimeMillis()); + v.put(LOG_clmMESSAGE, message); + + try { + db.insertOrThrow(tblLOG, null, v); + } catch(SQLException ex) { + warnException(ex, "Exception writing log entry to db: %s", message); + } + } + + Cursor getLogEntries() { + return db.query(tblLOG, + cols(LOG_clmID, LOG_clmTIMESTAMP, LOG_clmMESSAGE), + ALL, NO_ARGS, + NO_GROUP, NO_GROUP, + SortDirection.DESC.apply(LOG_clmID), + logEntryLimitString); + } + + void cleanLogs() { + rawUpdateOrDelete("DELETE FROM %s WHERE %s < (SELECT %s FROM %s LIMIT (SELECT (COUNT(*) - ?) FROM %s),1)", + cols(tblLOG, LOG_clmID, LOG_clmID, tblLOG, tblLOG), + args(logEntryLimit)); + } + +//> WoMessage HANDLERS + boolean store(WoMessage m) { + log("store() :: %s", m); + try { + long id = db.insertOrThrow(tblWO_MESSAGE, null, getContentValues(m)); + + if(id != -1) { + storeStatusUpdate(m, m.status, null, m.lastAction); + return true; + } else { + return false; + } + } catch(SQLiteConstraintException ex) { + // Likely this is because a message with this ID already exists. If so, + // we should update that message so that its status is synched with the + // server. This should stop the server from re-sending the same message + // repeatedly. + logEvent(ctx, "Message %s appears to be in database already; will be updated.", m); + return touch(m); + } catch(SQLException ex) { + warnException(ex, "Exception writing WoMessage to db: %s", m); + return false; + } + } + + void setFailed(WoMessage m, String failureReason) { + // Hard fail message and reset retries. + updateStatus(m, WoMessage.Status.PENDING, WoMessage.Status.FAILED, failureReason, 0); + } + + boolean updateStatus(WoMessage m, WoMessage.Status newStatus) { + return updateStatus(m, m.status, newStatus); + } + + boolean updateStatus(WoMessage m, WoMessage.Status newStatus, int retries) { + return updateStatus(m, m.status, newStatus,null, retries); + } + + boolean updateStatus(WoMessage m, WoMessage.Status oldStatus, WoMessage.Status newStatus) { + if(newStatus == WoMessage.Status.FAILED) + throw new IllegalArgumentException("updateStatus() should not be called with newStatus==FAILED. Use setFailed()."); + + return updateStatus(m, oldStatus, newStatus, null, m.retries); + } + + private boolean updateStatus(WoMessage m, WoMessage.Status oldStatus, WoMessage.Status newStatus, String failureReason, int retries) { + log("updateStatus() :: %s :: %s -> %s (%s)", m, oldStatus, newStatus, failureReason); + + if((newStatus == WoMessage.Status.FAILED) == (failureReason == null)) + throw new IllegalArgumentException(String.format( + "Give failureReason iff new status == FAILED (newStatus=%s, failureReason=%s)", + newStatus, + failureReason)); + + long timestamp = System.currentTimeMillis(); + + ContentValues v = new ContentValues(); + v.put(WOM_clmSTATUS, newStatus.toString()); + v.put(WOM_clmFAILURE_REASON, failureReason); + v.put(WOM_clmLAST_ACTION, timestamp); + v.put(WOM_clmRETRIES, retries); + + int affected; + if(oldStatus == null) { + affected = db.update(tblWO_MESSAGE, v, eq(WOM_clmID), args(m.id)); + } else { + affected = db.update(tblWO_MESSAGE, v, eq(WOM_clmID, WOM_clmSTATUS), args(m.id, oldStatus)); + } + + if(affected > 0) { + storeStatusUpdate(m, newStatus, failureReason, timestamp); + return true; + } else { + return false; + } + } + + void setStatusForwarded(WoMessage.StatusUpdate u) { + log("setStatusForwarded() :: %s", u); + + ContentValues v = new ContentValues(); + v.put(WOS_clmNEEDS_FORWARDING, FALSE); + + db.update(tblWO_STATUS, v, eq(WOS_clmID, WOS_clmSTATUS), args(u.id, u.newStatus)); + } + + private boolean touch(WoMessage m) { + log("touch() :: %s", m); + + ContentValues suV = new ContentValues(); + suV.put(WOS_clmNEEDS_FORWARDING, TRUE); + + Cursor c = null; + try { + int affectedRows = rawUpdateOrDelete("UPDATE %s SET %s=? WHERE %s IN (SELECT %s FROM %s WHERE %s=? ORDER BY %s DESC LIMIT 1)", + cols(tblWO_STATUS, WOS_clmNEEDS_FORWARDING, WOS_clmID, WOS_clmID, tblWO_STATUS, WOS_clmMESSAGE_ID, WOS_clmID), + args(TRUE, m.id)); + if(affectedRows > 0) { + ContentValues mV = new ContentValues(); + mV.put(WOM_clmLAST_ACTION, System.currentTimeMillis()); + db.update(tblWO_MESSAGE, mV, eq(WOM_clmID), args(m.id)); + + return true; + } else return false; + } finally { + if(c != null) c.close(); + } + } + + private void storeStatusUpdate(WoMessage m, WoMessage.Status newStatus, String failureReason, long timestamp) { + try { + db.insertOrThrow(tblWO_STATUS, null, getContentValues(m, newStatus, failureReason, timestamp, true)); + } catch(SQLException ex) { + warnException(ex, "Exception writing WO StatusUpdate [%s] to db for WoMessage: %s", newStatus, m); + } + } + + private ContentValues getContentValues(WoMessage m) { + ContentValues v = new ContentValues(); + v.put(WOM_clmID, m.id); + v.put(WOM_clmSTATUS, m.status.toString()); + v.put(WOM_clmFAILURE_REASON, m.status == WoMessage.Status.FAILED ? m.getFailureReason() : null); + v.put(WOM_clmLAST_ACTION, System.currentTimeMillis()); + v.put(WOM_clmTO, m.to); + v.put(WOM_clmCONTENT, m.content); + v.put(WOM_clmRETRIES, m.retries); + return v; + } + + private ContentValues getContentValues(WoMessage m, WoMessage.Status newStatus, String failureReason, long timestamp, boolean needsForwarding) { + ContentValues v = new ContentValues(); + v.put(WOS_clmMESSAGE_ID, m.id); + v.put(WOS_clmSTATUS, newStatus.toString()); + v.put(WOS_clmFAILURE_REASON, failureReason); + v.put(WOS_clmTIMESTAMP, timestamp); + v.put(WOS_clmNEEDS_FORWARDING, bool(needsForwarding)); + return v; + } + + WoMessage getWoMessage(String id) { + List matches = getWoMessages(eq(WOM_clmID), args(id), null, 1); + if(matches.isEmpty()) return null; + return matches.get(0); + } + + Cursor getWoMessages(int maxCount) { + return getWoMessageCursor(null, null, SortDirection.DESC, maxCount); + } + + List getWoMessages(int maxCount, WoMessage.Status status) { + return getWoMessages(eq(WOM_clmSTATUS), args(status), SortDirection.ASC, maxCount); + } + + List getWoMessageStatusUpdates(int maxCount) { + Cursor c = null; + try { + c = db.query(tblWO_STATUS, + WOS_SELECT_COLS, + eq(WOS_clmNEEDS_FORWARDING), args(TRUE), + NO_GROUP, NO_GROUP, + DEFAULT_SORT_ORDER, + Integer.toString(maxCount)); + + int count = c.getCount(); + log("getWoMessageStatusUpdates() :: item fetch count: %s", count); + ArrayList list = new ArrayList<>(count); + c.moveToFirst(); + while(count-- > 0) { + list.add(woMessageStatusUpdateFrom(c)); + c.moveToNext(); + } + return list; + } finally { + if(c != null) c.close(); + } + } + + private List getWoMessages(String selection, String[] selectionArgs, SortDirection sort, int maxCount) { + Cursor c = null; + try { + c = getWoMessageCursor(selection, selectionArgs, sort, maxCount); + + int count = c.getCount(); + log("getWoMessages() :: item fetch count: %s", count); + ArrayList list = new ArrayList<>(count); + c.moveToFirst(); + while(count-- > 0) { + list.add(woMessageFrom(c)); + c.moveToNext(); + } + return list; + } finally { + if(c != null) c.close(); + } + } + + private Cursor getWoMessageCursor(String selection, String[] selectionArgs, SortDirection sort, int maxCount) { + return db.query(tblWO_MESSAGE, + cols(WOM_clmID, WOM_clmSTATUS, WOM_clmFAILURE_REASON, WOM_clmLAST_ACTION, WOM_clmTO, WOM_clmCONTENT, WOM_clmRETRIES), + selection, selectionArgs, + NO_GROUP, NO_GROUP, + sort == null? null: sort.apply(WOM_clmLAST_ACTION), + Integer.toString(maxCount)); + } + + public static WoMessage woMessageFrom(Cursor c) { + String id = c.getString(0); + WoMessage.Status status = WoMessage.Status.valueOf(c.getString(1)); + String failureReason = c.getString(2); + long lastAction = c.getLong(3); + String to = c.getString(4); + String content = c.getString(5); + int retries = c.getInt(6); + + return new WoMessage(id, status, failureReason, lastAction, to, content, retries); + } + + private static WoMessage.StatusUpdate woMessageStatusUpdateFrom(Cursor c) { + long id = c.getLong(0); + String messageId = c.getString(1); + WoMessage.Status status = WoMessage.Status.valueOf(c.getString(2)); + String failureReason = c.getString(3); + long timestamp = c.getLong(4); + + return new WoMessage.StatusUpdate(id, messageId, status, failureReason, timestamp); + } + + public List getStatusUpdates(WoMessage m) { + Cursor c = null; + try { + c = db.query(tblWO_STATUS, + WOS_SELECT_COLS, + eq(WOS_clmMESSAGE_ID), args(m.id), + NO_GROUP, NO_GROUP, + DEFAULT_SORT_ORDER, + NO_LIMIT); + + int count = c.getCount(); + log("getStatusUpdates(WoMessage) :: item fetch count: %s", count); + ArrayList list = new ArrayList<>(count); + c.moveToFirst(); + while(count-- > 0) { + list.add(woMessageStatusUpdateFrom(c)); + c.moveToNext(); + } + return list; + } finally { + if(c != null) c.close(); + } + } + +//> WtMessage HANDLERS + @SuppressFBWarnings("RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE") // for #117 + boolean store(SmsMessage sms) { + SmsUdh multi = SmsUdh.from(sms); + + if(multi == null || multi.totalParts == 1) { + WtMessage m = new WtMessage( + sms.getOriginatingAddress(), + sms.getMessageBody(), + sms.getTimestampMillis()); + return store(m); + } else { + try { + long id = db.insertOrThrow(tblWT_MESSAGE_PART, null, getContentValues(sms, multi)); + + if(id == -1) return false; + } catch(SQLiteConstraintException ex) { + logException(ex, "Failed to save multipart fragment - it likely already exists in the database."); + return false; + } + + Cursor c = null; + db.beginTransaction(); + + try { + c = db.query(tblWT_MESSAGE_PART, + cols(WMP_clmCONTENT), + eq(WMP_clmFROM, WMP_clmMP_REF), + args(sms.getOriginatingAddress(), multi.multipartRef), + NO_GROUP, NO_GROUP, + SortDirection.ASC.apply(WMP_clmMP_PART)); + if(c.getCount() == multi.totalParts) { + StringBuilder bob = new StringBuilder(); + while(c.moveToNext()) { + bob.append(c.getString(0)); + } + boolean success = store(new WtMessage(sms.getOriginatingAddress(), bob.toString(), multi.sentTimestamp)); + if(success) { + rawUpdateOrDelete("DELETE FROM %s WHERE %s=? AND %s=?", + cols(tblWT_MESSAGE_PART, WMP_clmFROM, WMP_clmMP_REF), + args(sms.getOriginatingAddress(), multi.multipartRef)); + db.setTransactionSuccessful(); + } else { + return false; + } + } + return true; + } finally { + db.endTransaction(); + if(c != null) c.close(); + } + } + } + + boolean store(WtMessage m) { + log("store() :: %s", m); + external.log(m); + try { + long id = db.insertOrThrow(tblWT_MESSAGE, null, getContentValues(m)); + + if(id != -1) { + storeStatusUpdate(m, m.getStatus(), m.getLastAction()); + return true; + } else { + return false; + } + } catch(SQLException ex) { + warnException(ex, "Exception writing WtMessage to db: %s", m); + return false; + } + } + + boolean storeWithoutLoggingExternally(WtMessage m) { + log("storeWithoutLoggingExternally() :: %s", m); + try { + long id = db.insert(tblWT_MESSAGE, null, getContentValues(m)); + + if(id != -1) { + log("storeWithoutLoggingExternally() :: save successful."); + storeStatusUpdate(m, m.getStatus(), m.getLastAction()); + return true; + } else { + log("storeWithoutLoggingExternally() :: save failed."); + return false; + } + } catch(SQLException ex) { + warnException(ex, "Exception writing WtMessage to db: %s", m); + return false; + } + } + + void updateStatusFrom(WtMessage.Status oldStatus, WtMessage m) { + WtMessage.Status newStatus = m.getStatus(); + log("updateStatusFrom() :: %s :: %s -> %s", m, oldStatus, newStatus); + + long timestamp = System.currentTimeMillis(); + + ContentValues v = new ContentValues(); + v.put(WTM_clmSTATUS, newStatus.toString()); + v.put(WTM_clmLAST_ACTION, m.getLastAction()); + + int affected; + if(oldStatus == null) { + affected = db.update(tblWT_MESSAGE, v, eq(WTM_clmID), args(m.id)); + } else { + affected = db.update(tblWT_MESSAGE, v, eq(WTM_clmID, WTM_clmSTATUS), args(m.id, oldStatus)); + } + + if(affected > 0) { + storeStatusUpdate(m, newStatus, timestamp); + } + } + + private void storeStatusUpdate(WtMessage m, WtMessage.Status newStatus, long timestamp) { + try { + db.insertOrThrow(tblWT_STATUS, null, getContentValues(m, newStatus, timestamp)); + } catch(SQLException ex) { + warnException(ex, "Exception writing WT StatusUpdate [%s] to db for WtMessage: %s", newStatus, m); + } + } + + private ContentValues getContentValues(WtMessage m, WtMessage.Status newStatus, long timestamp) { + ContentValues v = new ContentValues(); + v.put(WTS_clmMESSAGE_ID, m.id); + v.put(WTS_clmSTATUS, newStatus.toString()); + v.put(WTS_clmTIMESTAMP, timestamp); + return v; + } + + private ContentValues getContentValues(WtMessage m) { + ContentValues v = new ContentValues(); + v.put(WTM_clmID, m.id); + v.put(WTM_clmSTATUS, m.getStatus().toString()); + v.put(WTM_clmLAST_ACTION, m.getLastAction()); + v.put(WTM_clmFROM, m.from); + v.put(WTM_clmCONTENT, m.content); + v.put(WTM_clmSMS_SENT, m.smsSent); + v.put(WTM_clmSMS_RECEIVED, m.smsReceived); + return v; + } + + private ContentValues getContentValues(SmsMessage sms, SmsUdh multi) { + ContentValues v = new ContentValues(); + + v.put(WMP_clmFROM, sms.getOriginatingAddress()); + v.put(WMP_clmCONTENT, sms.getMessageBody()); + v.put(WMP_clmSENT, multi.sentTimestamp); + v.put(WMP_clmRECEIVED, sms.getTimestampMillis()); + v.put(WMP_clmMP_REF, multi.multipartRef); + v.put(WMP_clmMP_PART, multi.partNumber); + v.put(WMP_clmMP_TOTAL_PARTS, multi.totalParts); + + return v; + } + + WtMessage getWtMessage(String id) { + List matches = getWtMessages(eq(WOM_clmID), args(id), null, 1); + if(matches.isEmpty()) return null; + return matches.get(0); + } + + Cursor getWtMessages(int maxCount) { + return getWtMessageCursor(NO_CRITERIA, NO_ARGS, SortDirection.DESC, maxCount); + } + + List getWtMessages(int maxCount, WtMessage.Status status) { + return getWtMessages(eq(WTM_clmSTATUS), args(status), SortDirection.ASC, maxCount); + } + + private List getWtMessages(String selection, String[] selectionArgs, SortDirection sort, int maxCount) { + Cursor c = null; + try { + c = getWtMessageCursor(selection, selectionArgs, sort, maxCount); + + int count = c.getCount(); + log("getWtMessages() :: item fetch count: %s", count); + ArrayList list = new ArrayList<>(count); + c.moveToFirst(); + while(count-- > 0) { + list.add(new WtMessage( + c.getString(0), + WtMessage.Status.valueOf(c.getString(1)), + c.getLong(2), + c.getString(3), + c.getString(4), + c.getLong(5), + c.getLong(6))); + c.moveToNext(); + } + return list; + } finally { + if(c != null) c.close(); + } + } + + private Cursor getWtMessageCursor(String selection, String[] selectionArgs, SortDirection sort, int maxCount) { + return db.query(tblWT_MESSAGE, + cols(WTM_clmID, WTM_clmSTATUS, WTM_clmLAST_ACTION, WTM_clmFROM, WTM_clmCONTENT, WTM_clmSMS_SENT, WTM_clmSMS_RECEIVED), + selection, selectionArgs, + NO_GROUP, NO_GROUP, + sort == null? DEFAULT_SORT_ORDER: sort.apply(WTM_clmLAST_ACTION), + Integer.toString(maxCount)); + } + + static WtMessage wtMessageFrom(Cursor c) { + String id = c.getString(0); + WtMessage.Status status = WtMessage.Status.valueOf(c.getString(1)); + long lastAction = c.getLong(2); + String from = c.getString(3); + String content = c.getString(4); + long smsSent = c.getLong(5); + long smsReceived = c.getLong(6); + + return new WtMessage(id, status, lastAction, from, content, smsSent, smsReceived); + } + + private static WtMessage.StatusUpdate wtMessageStatusUpdateFrom(Cursor c) { + long id = c.getLong(0); + String messageId = c.getString(1); + WtMessage.Status status = WtMessage.Status.valueOf(c.getString(2)); + long timestamp = c.getLong(3); + + return new WtMessage.StatusUpdate(id, messageId, status, timestamp); + } + + public List getStatusUpdates(WtMessage m) { + Cursor c = null; + try { + c = db.query(tblWT_STATUS, + cols(WTS_clmID, WTS_clmMESSAGE_ID, WTS_clmSTATUS, WTS_clmTIMESTAMP), + eq(WTS_clmMESSAGE_ID), args(m.id), + NO_GROUP, NO_GROUP, + DEFAULT_SORT_ORDER, + NO_LIMIT); + + int count = c.getCount(); + log("getStatusUpdates(WtMessage) :: item fetch count: %s", count); + ArrayList list = new ArrayList<>(count); + c.moveToFirst(); + while(count-- > 0) { + list.add(wtMessageStatusUpdateFrom(c)); + c.moveToNext(); + } + return list; + } finally { + if(c != null) c.close(); + } + } + +//> DB SEEDING + private void seed() { + LogMessages: { + for(int i=0; i<50; ++i) { + storeLogEntry("Seed log entry " + i); + } + } + + WtMessages: { + for(int i=0; i<10; ++i) { + store(new WtMessage("+254789123123", "hello from kenya " + i, i * 3600L * 24L)); + store(new WtMessage("+34678123123", "hello from spain " + i, i * 3600L * 24L)); + store(new WtMessage("+447890123123", "hello from uk " + i, i * 3600L * 24L)); + } + + for(int i=0; i<20; ++i) { + store(new WtMessage(randomPhoneNumber(), randomSmsContent(), 0)); + } + } + + WoMessages: { + for(int i=0; i<10; ++i) { + store(new WoMessage(randomUUID().toString(), "+254789123123", "hello kenya " + i)); + store(new WoMessage(randomUUID().toString(), "+34678123123", "hello spain " + i)); + store(new WoMessage(randomUUID().toString(), "+447890123123", "hello uk " + i)); + } + + for(int i=0; i<20; ++i) { + store(new WoMessage(randomUUID().toString(), randomPhoneNumber(), randomSmsContent())); + } + } + } + + private int rawUpdateOrDelete(String statement, String[] cols, String... args) { + return rawUpdateOrDelete(db, statement, cols, args); + } + +//> MESSAGE REPORT + @SuppressWarnings("PMD.UseConcurrentHashMap") + MessageReport generateMessageReport() { + long womCount = 0; + long wtmCount = 0; + Map statusCounts = new HashMap<>(); + + WoMessages: { + Cursor c = null; + try { + c = db.rawQuery("SELECT " + WOM_clmSTATUS + ",COUNT(" + WOM_clmSTATUS + ") FROM " + + tblWO_MESSAGE + " GROUP BY " + WOM_clmSTATUS, + args()); + while(c.moveToNext()) { + WoMessage.Status status = WoMessage.Status.valueOf(c.getString(0)); + long count = c.getLong(1); + + statusCounts.put(status, count); + + womCount += count; + } + } finally { + if(c != null) c.close(); + } + } + + WtMessages: { + Cursor c = null; + try { + c = db.rawQuery("SELECT " + WTM_clmSTATUS + ",COUNT(" + WTM_clmSTATUS + ") FROM " + + tblWT_MESSAGE + " GROUP BY " + WTM_clmSTATUS, + args()); + while(c.moveToNext()) { + WtMessage.Status status = WtMessage.Status.valueOf(c.getString(0)); + long count = c.getLong(1); + + statusCounts.put(status, count); + + wtmCount += count; + } + } finally { + if(c != null) c.close(); + } + } + + return new MessageReport(womCount, wtmCount, statusCounts); + } + +//> STATIC HELPERS + private static String[] cols(String... args) { + return args; + } + + private static String lt(String col) { // NOPMD + return col + "0; --i) + s.bindString(i, args[i-1]); + return s.executeUpdateDelete(); + } +} + +enum SortDirection { + ASC, DESC; + + public String apply(String column) { + return column + " " + this.toString(); + } +} + +class MessageReport { + final long womCount; + final long wtmCount; + private final Map statusCounts; + + MessageReport(long womCount, long wtmCount, Map statusCounts) { + this.womCount = womCount; + this.wtmCount = wtmCount; + this.statusCounts = statusCounts; + } + + public long getCount(WoMessage.Status s) { return getSafely(s); } + public long getCount(WtMessage.Status s) { return getSafely(s); } + + private long getSafely(Object k) { + Long val = statusCounts.get(k); + return val == null ? 0 : val; + } +} diff --git a/src/main/java/medic/gateway/alert/DebugUtils.java b/src/main/java/medic/gateway/alert/DebugUtils.java new file mode 100644 index 0000000..d74323d --- /dev/null +++ b/src/main/java/medic/gateway/alert/DebugUtils.java @@ -0,0 +1,30 @@ +package medic.gateway.alert; + +import java.util.Random; + +public final class DebugUtils { + private static final String[] RANDOM_WORDS = { + "I", "a", "action", "admirable", "air", "all", "an", "and", "angel", "animals", "appears", "apprehension", "beauty", "brave", "but", "canopy", "congregation", "custom", "delights", "disposition", "dust", "earth", "excellent", "exercises", "express", "faculty", "fire", "firmament", "forgone", "form", "foul", "frame", "fretted", "god", "goes", "golden", "goodly", "have", "heavily", "how", "in", "indeed", "infinite", "is", "it", "know", "late", "like", "look", "lost", "majestical", "man", "me", "mirth", "most", "moving", "my", "neither", "no", "noble", "nor", "not", "of", "other", "overhanging", "paragon", "pestilential", "piece", "promontory", "quintessence", "reason", "roof", "seems", "so", "sterile", "than", "that", "the", "thing", "this", "to", "vapours", "what", "wherefore", "why", "with", "woman", "work", "world", "yet", "you", + }; + + private DebugUtils() {} + + public static String randomPhoneNumber() { + Random r = new Random(); + StringBuilder bob = new StringBuilder(); + bob.append('+'); + for(int i=0; i<10; ++i) bob.append(r.nextInt(10)); + return bob.toString(); + } + + public static String randomSmsContent() { + Random r = new Random(); + int wordCount = r.nextInt(20) + 1; + StringBuilder bob = new StringBuilder(); + for(int i=0; ilast received part. More precise support + * for multipart messages would be helpful, although care would need to + * be taken to reflect these changes in the API. + */ +@SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.ModifiedCyclomaticComplexity", "PMD.StdCyclomaticComplexity"}) +class DeliveryReportHandler { + /** + * Mask for differentiating GSM and CDMA message statuses. + * @see https://developer.android.com/reference/android/telephony/SmsMessage.html#getStatus%28%29 + */ + private static final int GSM_STATUS_MASK = 0xFF; + + private static final WoMessage.Status ANY_STATUS = null; + + private final Context ctx; + +//> CONSTRUCTORS + public DeliveryReportHandler(Context ctx) { + this.ctx = ctx; + } + +//> PUBLIC API + public void handle(Intent intent) { + String id = intent.getStringExtra("id"); + int part = intent.getIntExtra("part", -1); + logEvent(ctx, "Received delivery report for message %s part %s.", id, part); + + Db db = Db.getInstance(ctx); + WoMessage m = db.getWoMessage(id); + if(m == null) { + logEvent(ctx, "Could not find SMS %s in database for delivery report.", id); + return; + } + + int status = createFromPdu(intent).getStatus(); + + logEvent(ctx, "Delivery status: 0x" + toHexString(status)); + + if((status & GSM_STATUS_MASK) == status) { + handleGsmDelivery(ctx, status, m); + } else { + handleCdmaDelivery(ctx); + } + } + +//> INTERNAL METHODS + /** + * Decode the status value as per ETSI TS 123 040 V13.1.0 (2016-04) 9.2.3.15 (TP-Status (TP-ST)). + * @see http://www.etsi.org/deliver/etsi_ts/123000_123099/123040/13.01.00_60/ts_123040v130100p.pdf + */ + @SuppressWarnings("PMD.EmptyIfStmt") + private void handleGsmDelivery(Context ctx, int status, WoMessage m) { + // Detail of the failure. Must be set for FAILED messages. + String fDetail = null; + + Db db = Db.getInstance(ctx); + + if(status < 0x20) { + //> Short message transaction completed + switch(status) { + case 0x00: //> Short message received by the SME + case 0x01: //> Short message forwarded by the SC to the SME but the SC is unable to confirm delivery + db.updateStatus(m, ANY_STATUS, DELIVERED); + return; + case 0x02: // Short message replaced by the SC + // Not sure what to do with this. + } + if(status < 0x10) { + // These values are "reserved" + } else { + //> Values specific to each SC + } + // For now, we will just ignore statuses that we don't understand. + return; + } else if(status < 0x40) { + //> Temporary error, SC still trying to transfer SM + // no need to report this status yet + return; + } else if(status < 0x60) { + //> Permanent error, SC is not making any more transfer attempts + switch(status) { + case 0x40: fDetail = "Remote procedure error"; break; + case 0x41: fDetail = "Incompatible destination"; break; + case 0x42: fDetail = "Connection rejected by SME"; break; + case 0x43: fDetail = "Not obtainable"; break; + case 0x44: fDetail = "Quality of service not available"; break; + case 0x45: fDetail = "No interworking available"; break; + case 0x46: fDetail = "SM Validity Period Expired"; break; + case 0x47: fDetail = "SM Deleted by originating SME"; break; + case 0x48: fDetail = "SM Deleted by SC Administration"; break; + case 0x49: fDetail = "SM does not exist"; break; + default: + if(status < 0x50) fDetail = String.format("Permanent error (Reserved: 0x%s)", toHexString(status)); + else fDetail = "SMSC-specific permanent error: 0x" + toHexString(status); + } + } else if(status <= 0x7f) { + //> Temporary error, SC is not making any more transfer attempts + switch(status) { + case 0x60: fDetail = "Congestion"; break; + case 0x61: fDetail = "SME busy"; break; + case 0x62: fDetail = "No response from SME"; break; + case 0x63: fDetail = "Service rejected"; break; + case 0x64: fDetail = "Quality of service not available"; break; + case 0x65: fDetail = "Error in SME"; break; + default: + if(status < 0x70) fDetail = String.format("Temporary error (Reserved: 0x%s)", toHexString(status)); + else fDetail = "SMSC-specific temporary error: 0x" + toHexString(status); + } + } else throw new IllegalStateException("Unexpected status (> 0x7F) : 0x" + toHexString(status)); + + db.setFailed(m, "Delivery failed: " + fDetail); + logEvent(ctx, "Delivering message to %s failed (cause: %s)", m.to, fDetail); + } + + private void handleCdmaDelivery(Context ctx) { + logEvent(ctx, "Delivery reports not yet supported on CDMA devices."); + } +} diff --git a/src/main/java/medic/gateway/alert/ExternalLog.java b/src/main/java/medic/gateway/alert/ExternalLog.java new file mode 100644 index 0000000..c896793 --- /dev/null +++ b/src/main/java/medic/gateway/alert/ExternalLog.java @@ -0,0 +1,166 @@ +package medic.gateway.alert; + +import android.content.Context; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; + +import org.json.JSONObject; + +import static android.os.Environment.MEDIA_MOUNTED; +import static android.os.Environment.getExternalStorageDirectory; +import static android.os.Environment.getExternalStorageState; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.GatewayLog.warn; +import static medic.gateway.alert.Utils.json; + +/** + * All non-private methods dealing with the filesystem should be {@code synchronized}. + * All non-private methods must not throw {@code Exception}s. + */ +// TODO rename this as PersistentLog or EmergencyMessageLog? +@SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") +class ExternalLog { + private static ExternalLog _instance; + + private final File f; + + private ExternalLog(File f) { this.f = f; } + + static synchronized ExternalLog getInstance(Context ctx) { + if(_instance == null) { + String storageState = getExternalStorageState(); + if(!MEDIA_MOUNTED.equals(storageState)) { + warn("Cannot use external log file. Storage state is currently: %s", storageState); + } + + File directory = new File(getExternalStorageDirectory(), "Documents"); + boolean dirCreated = directory.mkdirs(); + if(!dirCreated && !directory.exists()) + warn("Failed to create directory for saving external logfile at %s. External logging probably won't work.", directory.getAbsolutePath()); + + File f = new File(directory, ".cht-gateway.json.log"); + _instance = new ExternalLog(f); + } + return _instance; + } + + synchronized boolean shouldProcess() { + trace(this, "shouldProcess() :: f.len=%s", f.length()); + return f.length() > 0; + } + + synchronized void process(Context ctx) { + trace(this, "process()"); + FileReader fr = null; + BufferedReader br = null; + + try { + Db db = Db.getInstance(ctx); + + fr = new FileReader(f); + br = new BufferedReader(fr); + + String line; + while((line = br.readLine()) != null) { + trace(this, "process() :: line=%s", line); + processLine(db, line); + } + + boolean deleteSuccess = f.delete(); + + if(!deleteSuccess) trace(this, "process() :: failed to delete log file after processing."); + } catch(Exception ex) { + logException(ex, "Problem processing external log file at %s", f.getAbsolutePath()); + } finally { + closeSafely(br); + closeSafely(fr); + } + } + + synchronized void log(WtMessage m) { + trace(this, "log() :: %s", m); + try { + JSONObject json = json( + "type", "wt_message", + "doc", json( + "id", m.id, + "from", m.from, + "content", m.content, + "sms_sent", m.smsSent, + "sms_received", m.smsReceived + ) + ); + writeLine(json); + } catch(Exception ex) { + logException(ex, "Problem writing WtMessage to log: %s", m); + } + } + + private void processLine(Db db, String line) { + trace(this, "processLine() :: line=%s", line); + try { + JSONObject json = new JSONObject(line); + String type = json.getString("type"); + JSONObject doc = json.getJSONObject("doc"); + + switch(type) { + case "wt_message": + // N.B. if adding any new fields here in the future, be + // careful to fetch them using `optString()`, `optLong()` + // etc. and specify the same defaults as in Db. + WtMessage m = new WtMessage( + doc.getString("id"), + WtMessage.Status.WAITING, + System.currentTimeMillis(), + doc.getString("from"), + doc.getString("content"), + doc.getLong("sms_sent"), + doc.getLong("sms_received")); + db.storeWithoutLoggingExternally(m); + break; + default: throw new UnrecongisedExternalLogType(type); + } + } catch(Exception ex) { + logException(ex, "Problem processing line: %s", line); + } + } + + // TODO We probably don't want to re-open it every time we process anything. Consider how we keep a file handle open for writing to the file. How will it be safely closed when the app exits? What if it wasn't safely closed last time? + @SuppressWarnings("PMD.SignatureDeclareThrowsException") + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE") + private void writeLine(JSONObject json) throws IOException { + trace(this, "writeLine() :: json=%s", json); + f.createNewFile(); + FileWriter fw = null; + BufferedWriter bw = null; + try { + fw = new FileWriter(f, true); + bw = new BufferedWriter(fw); + + bw.write(json.toString()); + bw.newLine(); + } finally { + closeSafely(bw); + closeSafely(fw); + } + } + + private void closeSafely(Closeable c) { + if(c != null) try { c.close(); } catch(Exception ex) { logException(ex, "Problem while closing; will be ignored."); } + } +} + +class UnrecongisedExternalLogType extends Exception { + public UnrecongisedExternalLogType(String type) { + super(type); + } +} diff --git a/src/main/java/medic/gateway/alert/ExternalLogProcessorActivity.java b/src/main/java/medic/gateway/alert/ExternalLogProcessorActivity.java new file mode 100644 index 0000000..2055722 --- /dev/null +++ b/src/main/java/medic/gateway/alert/ExternalLogProcessorActivity.java @@ -0,0 +1,48 @@ +package medic.gateway.alert; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; + +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.startSettingsOrMainActivity; + +public class ExternalLogProcessorActivity extends Activity { + private Thinking thinking; + +//> LIFECYCLE + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + trace(this, "Starting..."); + + boolean shouldProcess = ExternalLog.getInstance(this).shouldProcess(); + trace(this, "shouldProcess? %s", shouldProcess); + + if(shouldProcess) { + setContentView(R.layout.external_log_processor); + + processExternalLog(); + } else startSettingsOrMainActivity(this); + + finish(); + } + + @Override public void onDestroy() { + if(thinking != null) thinking.dismiss(); + super.onDestroy(); + } + +//> PRIVATE HELPERS + private void processExternalLog() { + thinking = Thinking.show(this, R.string.txtProcessingExternalLog); + AsyncTask.execute(new Runnable() { + public void run() { + Context ctx = ExternalLogProcessorActivity.this; + ExternalLog.getInstance(ctx).process(ctx); + thinking.dismiss(); + startSettingsOrMainActivity(ctx); + } + }); + } +} diff --git a/src/main/java/medic/gateway/alert/GatewayEventLogActivity.java b/src/main/java/medic/gateway/alert/GatewayEventLogActivity.java new file mode 100644 index 0000000..371f140 --- /dev/null +++ b/src/main/java/medic/gateway/alert/GatewayEventLogActivity.java @@ -0,0 +1,26 @@ +package medic.gateway.alert; + +import android.os.Bundle; +import android.view.View; +import android.widget.Button; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +public class GatewayEventLogActivity extends FragmentActivity { + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.event_log); + + ((Button) findViewById(R.id.btnRefreshLog)) + .setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { refreshList(); } + }); + } + + private void refreshList() { + Fragment genericFragment = getSupportFragmentManager().findFragmentById(R.id.lstGatewayEventLog); + GatewayEventLogFragment fragment = (GatewayEventLogFragment) genericFragment; + getSupportLoaderManager().restartLoader(GatewayEventLogFragment.LOADER_ID, null, fragment); + } +} diff --git a/src/main/java/medic/gateway/alert/GatewayEventLogFragment.java b/src/main/java/medic/gateway/alert/GatewayEventLogFragment.java new file mode 100644 index 0000000..3b0c6b3 --- /dev/null +++ b/src/main/java/medic/gateway/alert/GatewayEventLogFragment.java @@ -0,0 +1,73 @@ +package medic.gateway.alert; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.view.View; + +import androidx.fragment.app.ListFragment; +import androidx.loader.app.LoaderManager.LoaderCallbacks; +import androidx.loader.content.CursorLoader; +import androidx.loader.content.Loader; +import androidx.cursoradapter.widget.CursorAdapter; +import androidx.cursoradapter.widget.ResourceCursorAdapter; + +import static medic.gateway.alert.Utils.absoluteTimestamp; +import static medic.gateway.alert.Utils.setText; + +public class GatewayEventLogFragment extends ListFragment implements LoaderCallbacks { + public static final int LOADER_ID = 1; + + private Db db; + + @Override public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + this.db = Db.getInstance(getActivity()); + + GatewayEventLogEntryCursorAdapter adapter = new GatewayEventLogEntryCursorAdapter(getActivity()); + setListAdapter(adapter); + getLoaderManager().initLoader(LOADER_ID, null, this); + } + +//> LoaderCallbacks + public Loader onCreateLoader(int id, Bundle args) { + setListShown(false); + return new GatewayEventLogEntryCursorLoader(getActivity(), db); + } + + public void onLoadFinished(Loader loader, Cursor cursor) { + ((CursorAdapter) this.getListAdapter()).swapCursor(cursor); + setListShown(true); + } + + public void onLoaderReset(Loader loader) { + ((CursorAdapter) this.getListAdapter()).swapCursor(null); + } +} + +class GatewayEventLogEntryCursorAdapter extends ResourceCursorAdapter { + private static final int NO_FLAGS = 0; + + public GatewayEventLogEntryCursorAdapter(Context ctx) { + super(ctx, R.layout.event_log_item, null, NO_FLAGS); + } + + public void bindView(View v, Context ctx, Cursor c) { + setText(v, R.id.txtGatewayEventLogDate, absoluteTimestamp(c.getLong(1))); + setText(v, R.id.txtGatewayEventLogMessage, c.getString(2)); + } +} + +class GatewayEventLogEntryCursorLoader extends CursorLoader { + private final Db db; + + public GatewayEventLogEntryCursorLoader(Context ctx, Db db) { + super(ctx); + this.db = db; + } + + public Cursor loadInBackground() { + return db.getLogEntries(); + } +} diff --git a/src/main/java/medic/gateway/alert/GatewayLog.java b/src/main/java/medic/gateway/alert/GatewayLog.java new file mode 100644 index 0000000..b58919f --- /dev/null +++ b/src/main/java/medic/gateway/alert/GatewayLog.java @@ -0,0 +1,80 @@ +package medic.gateway.alert; + +import android.content.Context; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteFullException; + +import static android.util.Log.d; +import static android.util.Log.i; +import static android.util.Log.w; +import static medic.gateway.alert.BuildConfig.DEBUG; +import static medic.gateway.alert.BuildConfig.LOG_TAG; + +public final class GatewayLog { + private GatewayLog() {} + + public static void logEvent(Context ctx, String message, Object... extras) { + message = String.format(message, extras); + + i(LOG_TAG, message); + eventLogEntry(ctx, message); + } + + public static void warn(String message, Object... extras) { + message = String.format(message, extras); + + w(LOG_TAG, message); + } + + public static void warnEvent(Context ctx, String message, Object... extras) { + message = String.format(message, extras); + + w(LOG_TAG, message); + eventLogEntry(ctx, "WARNING: " + message); + } + + public static void trace(Object caller, String message, Object... extras) { + if(!DEBUG) return; + message = String.format(message, extras); + Class callerClass = caller instanceof Class ? (Class) caller : caller.getClass(); + d(LOG_TAG, String.format("%s :: %s", callerClass.getName(), message)); + } + + public static void logException(Context ctx, Exception ex, String message, Object... extras) { + message = forException(ex, message, extras); + + i(LOG_TAG, message, ex); + + // Do not try to save SQLiteFullException to the database - this + // will (unsurprisingly) fail if the database is full + if(!(ex instanceof SQLiteFullException)) { + eventLogEntry(ctx, message); + } + } + + public static void logException(Exception ex, String message, Object... extras) { + message = forException(ex, message, extras); + + i(LOG_TAG, message, ex); + } + + public static void warnException(Exception ex, String message, Object... extras) { + message = String.format(message, extras); + + w(LOG_TAG, message, ex); + } + + private static void eventLogEntry(Context ctx, String message) { + try { + Db.getInstance(ctx).storeLogEntry(message); + } catch(SQLiteException ex) { + logException(ex, "Could not write log entry to DB."); + } + } + + private static String forException(Exception ex, String message, Object... extras) { + return String.format("%s :: %s", + String.format(message, extras), + ex); + } +} diff --git a/src/main/java/medic/gateway/alert/HeadlessSmsSendService.java b/src/main/java/medic/gateway/alert/HeadlessSmsSendService.java new file mode 100644 index 0000000..b415060 --- /dev/null +++ b/src/main/java/medic/gateway/alert/HeadlessSmsSendService.java @@ -0,0 +1,22 @@ +package medic.gateway.alert; + +import android.app.IntentService; +import android.content.Intent; + +import static medic.gateway.alert.GatewayLog.logEvent; + +public class HeadlessSmsSendService extends IntentService { + private static final String CLASS_NAME = HeadlessSmsSendService.class.getName(); + + public HeadlessSmsSendService() { + super(CLASS_NAME); + } + + protected void onHandleIntent(Intent i) { + logEvent(this, "HeadlessSmsSendService :: received intent. No action will be taken (none implemented) [Intent: action=%s, data=%s, subject=%s, msg=%s]", + i.getAction(), + i.getDataString(), + i.getStringExtra(Intent.EXTRA_SUBJECT), + i.getStringExtra(Intent.EXTRA_TEXT)); + } +} diff --git a/src/main/java/medic/gateway/alert/IntentProcessor.java b/src/main/java/medic/gateway/alert/IntentProcessor.java new file mode 100644 index 0000000..64b4206 --- /dev/null +++ b/src/main/java/medic/gateway/alert/IntentProcessor.java @@ -0,0 +1,184 @@ +package medic.gateway.alert; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.telephony.SmsMessage; + +import static android.app.Activity.RESULT_OK; +import static android.telephony.SmsManager.RESULT_ERROR_GENERIC_FAILURE; +import static android.telephony.SmsManager.RESULT_ERROR_NO_SERVICE; +import static android.telephony.SmsManager.RESULT_ERROR_NULL_PDU; +import static android.telephony.SmsManager.RESULT_ERROR_RADIO_OFF; +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.SmsCompatibility.getMessagesFromIntent; +import static medic.gateway.alert.SmsCompatibility.SMS_DELIVER_ACTION; +import static medic.gateway.alert.SmsCompatibility.SMS_RECEIVED_ACTION; +import static medic.gateway.alert.WoMessage.Status.PENDING; +import static medic.gateway.alert.WoMessage.Status.SENT; +import static medic.gateway.alert.WoMessage.Status.UNSENT; + +public class IntentProcessor extends BroadcastReceiver { + static final String SENDING_REPORT = "medic.gateway.alert.SENDING_REPORT"; + static final String DELIVERY_REPORT = "medic.gateway.alert.DELIVERY_REPORT"; + + private final Capabilities app; + + public IntentProcessor() { + super(); + + this.app = Capabilities.getCapabilities(); + } + + public void onReceive(Context ctx, Intent intent) { + logEvent(ctx, "IntentProcessor.onReceive() :: %s", intent.getAction()); + + try { + switch(intent.getAction()) { + case SMS_RECEIVED_ACTION: + if(app.canBeDefaultSmsProvider() && app.isDefaultSmsProvider(ctx)) { + // on Android 4.4+ (kitkat), we will receive both SMS_RECEIVED_ACTION + // _and_ SMS_DELIVER_ACTION if we are the default SMS app. Ignoring. + break; + } + case SMS_DELIVER_ACTION: + handleSmsReceived(ctx, intent); + break; + case SENDING_REPORT: + new SendingReportHandler(ctx).handle(intent, getResultCode()); + break; + case DELIVERY_REPORT: + new DeliveryReportHandler(ctx).handle(intent); + break; + default: + throw new IllegalStateException("Unexpected intent: " + intent); + } + } catch(Exception ex) { + logException(ctx, ex, + "IntentProcessor threw exception '%s' when processing intent: %s", + ex.getClass(), ex.getMessage()); + } + } + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private void handleSmsReceived(Context ctx, Intent intent) { + Db db = Db.getInstance(ctx); + + for(SmsMessage m : getMessagesFromIntent(intent)) { + boolean success = db.store(m); + + if(!success) { + logEvent(ctx, "Failed to save received SMS to db: %s", m); + } + } + + new AsyncPoller().execute(ctx); + + // android >= 1.6 && android < 4.4: SMS_RECEIVED_ACTION is an + // ordered broadcast, so if we cancel it then it should never + // reach the inbox. On 4.4+, either (a) cht-gateway is the + // default SMS app, so the SMS will never reach the standard + // inbox, or (b) it is _not_ the default SMS app, in which case + // there is no way to delete the message. + abortBroadcast(); + } +} + +class AsyncPoller extends AsyncTask { + @Override + protected Void doInBackground(Context... contexts) { + Context ctx = contexts[0]; + try { + WebappPoller poller = new WebappPoller(ctx); + SimpleResponse lastResponse = poller.pollWebapp(); + + if(lastResponse == null || lastResponse.isError()) { + LastPoll.failed(ctx); + } else { + LastPoll.succeeded(ctx); + + try { + new SmsSender(ctx).sendUnsentSmses(); + } catch(Exception ex) { + logException(ctx, ex, "Exception caught trying to send SMSes: %s", ex.getMessage()); + } + } + } catch(Exception ex) { + logException(ctx, ex, "Exception caught trying to poll webapp: %s", ex.getMessage()); + LastPoll.failed(ctx); + } finally { + LastPoll.broadcast(ctx); + } + + return null; + } +} + +class SendingReportHandler { + private final Context ctx; + + SendingReportHandler(Context ctx) { + this.ctx = ctx; + } + + void handle(Intent intent, int resultCode) { + String id = intent.getStringExtra("id"); + int part = intent.getIntExtra("part", -1); + logEvent(ctx, "Received sending report for message %s part %s.", id, part); + + Db db = Db.getInstance(ctx); + WoMessage m = db.getWoMessage(id); + + if(m == null) { + logEvent(ctx, "Could not find SMS %s in database for sending report.", id); + } else if(resultCode == RESULT_OK) { + db.updateStatus(m, PENDING, SENT); + } else { + switch(resultCode) { + case RESULT_ERROR_GENERIC_FAILURE: + this.hardFail(db, m, getGenericFailureReason(intent)); + break; + case RESULT_ERROR_NO_SERVICE: + this.softFail(db, m, "no-service"); + break; + case RESULT_ERROR_NULL_PDU: + this.softFail(db, m, "null-pdu"); + break; + case RESULT_ERROR_RADIO_OFF: + this.softFail(db, m, "radio-off"); + break; + default: + this.hardFail(db, m, "unknown; resultCode=" + resultCode); + } + } + } + + private void softFail(Db db, WoMessage m, String failureReason) { + if (m.isMaxRetriesSoftFail()) { + // After limit is reached, WoMessage will hard fail. + // It can be retried manually later, if it soft fail again then retry process will restart from 0. + this.hardFail(db, m, failureReason); + } else { + int retries = m.retries + 1; + int waitTime = (m.calcWaitTimeRetry(retries) / 60) / 1000; // To minutes + db.updateStatus(m, UNSENT, retries); + logEvent(ctx, "Sending SMS to %s failed (cause: %s) Retry # %s in %s min", m.to, failureReason, retries, waitTime); + } + } + + private void hardFail(Db db, WoMessage m, String failureReason) { + db.setFailed(m, failureReason); + logEvent(ctx, "Sending message to %s failed (cause: %s) Not retrying", m.to, failureReason); + } + + private String getGenericFailureReason(Intent intent) { + if(intent.hasExtra("errorCode")) { + int errorCode = intent.getIntExtra("errorCode", -1); + return "generic; errorCode=" + errorCode; + } else { + return "generic; no errorCode supplied"; + } + } +} diff --git a/src/main/java/medic/gateway/alert/MessageListsActivity.java b/src/main/java/medic/gateway/alert/MessageListsActivity.java new file mode 100644 index 0000000..45de35b --- /dev/null +++ b/src/main/java/medic/gateway/alert/MessageListsActivity.java @@ -0,0 +1,209 @@ +package medic.gateway.alert; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.TabActivity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.ColorMatrix; +import android.graphics.ColorMatrixColorFilter; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.TabHost; + +import medic.android.ActivityBackgroundTask; + +import static medic.gateway.alert.Capabilities.getCapabilities; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.getAppName; +import static medic.gateway.alert.Utils.getAppVersion; +import static medic.gateway.alert.Utils.includeVersionNameInActivityTitle; +import static medic.gateway.alert.Utils.startSettingsActivity; +import static medic.gateway.alert.Utils.toast; + +@SuppressWarnings("deprecation") +public class MessageListsActivity extends TabActivity { + private static final long FIVE_MINUTES = 300000; + + private static final Class[] TAB_CLASSES = { + GatewayEventLogActivity.class, WoListActivity.class, WtListActivity.class, + }; + + private Thinking thinking; + +//> CLICK LISTENERS + private final DialogInterface.OnClickListener deleteOldDataHandler = new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + thinking = Thinking.show(MessageListsActivity.this, R.string.txtDeleteOldData_inProgress); + + new DeleteTask(MessageListsActivity.this).execute(); + } + }; + + private final DialogInterface.OnClickListener cancelDialogHandler = new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }; + + private final BroadcastReceiver pollUpdateReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context ctx, Intent i) { + if(LastPoll.isStatusUpdate(i)) { + updateForPollStatus(); + } + } + }; + +//> EVENT HANDLERS + @Override protected void onCreate(Bundle savedInstanceState) { + log("Starting..."); + super.onCreate(savedInstanceState); + + includeVersionNameInActivityTitle(this); + + TabHost tabHost = getTabHost(); + + String[] tabs = getResources().getStringArray(R.array.message_lists_tabs); + for(int i=0; i PRIVATE HELPERS + private void openSettings() { + startSettingsActivity(this, getCapabilities()); + finish(); + } + + private void updateForPollStatus() { + boolean pollingEnabled = SettingsStore.in(this).hasSettings() && + Settings.in(this).pollingEnabled; + LastPoll last = LastPoll.getFrom(this); + + char c; + Drawable icon; + + if(pollingEnabled && last != null) { + if(last.wasSuccessful && last.timestamp + FIVE_MINUTES > System.currentTimeMillis()) { + icon = baseIcon(); + c = '+'; + } else { + icon = redIcon(); + c = '!'; + } + } else { + icon = grayscaleIcon(); + c = '-'; + } + + this.getActionBar().setIcon(icon); + setTitle(String.format("%s %s v%s", c, getAppName(this), getAppVersion(this))); + } + + private Drawable baseIcon() { + return getResources().getDrawable(R.mipmap.ic_launcher).mutate(); + } + + private Drawable redIcon() { + Drawable icon = baseIcon(); + + icon.setColorFilter(0xffff0000, PorterDuff.Mode.MULTIPLY); + + return icon; + } + + private Drawable grayscaleIcon() { + Drawable icon = baseIcon(); + + ColorMatrix matrix = new ColorMatrix(); + matrix.setSaturation(0); + ColorMatrixColorFilter filter = new ColorMatrixColorFilter(matrix); + + icon.setColorFilter(filter); + + return icon; + } + + private void log(String message, Object...extras) { + trace(this, message, extras); + } + + private static class DeleteTask extends ActivityBackgroundTask { + DeleteTask(MessageListsActivity a) { + super(a); + } + + protected Integer doInBackground(String... s) { + try { + MessageListsActivity ctx = getRequiredCtx("DeleteTask.doInBackground()"); + return Db.getInstance(ctx).deleteOldData(); + } catch(RuntimeException ex) { + logException(ex, "Something went wrong deleting old data."); + return -1; + } + } + protected void onPostExecute(Integer deleteCount) { + MessageListsActivity ctx = getRequiredCtx("MessageListsActivity.onPostExecute()"); + String message = ctx.getResources().getQuantityString(R.plurals.txtOldDataDeleteCount, deleteCount); + toast(ctx, message, deleteCount); + ctx.thinking.dismiss(); + ctx.recreate(); + } + } +} diff --git a/src/main/java/medic/gateway/alert/MessageStatsDialog.java b/src/main/java/medic/gateway/alert/MessageStatsDialog.java new file mode 100644 index 0000000..85a0f9f --- /dev/null +++ b/src/main/java/medic/gateway/alert/MessageStatsDialog.java @@ -0,0 +1,55 @@ +package medic.gateway.alert; + +import android.app.Activity; +import android.app.AlertDialog; +import android.os.AsyncTask; + +import java.util.LinkedList; + +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.Utils.NO_CLICK_LISTENER; +import static medic.gateway.alert.Utils.showAlert; + +final class MessageStatsDialog { + private MessageStatsDialog() {} + + static Thinking show(final Activity a) { + final Thinking thinking = Thinking.show(a); + AsyncTask.execute(new Runnable() { + private final String string(int stringId, Object...args) { + return a.getString(stringId, args); + } + + public void run() { + try { + Db db = Db.getInstance(a); + LinkedList content = new LinkedList<>(); + MessageReport r = db.generateMessageReport(); + + content.add(string(R.string.lblMessageStats_title)); + + content.add(string(R.string.lblMessageStats_wt_total, r.wtmCount)); + for(WtMessage.Status s : WtMessage.Status.values()) { + content.add(string(R.string.lblMessageStats_forStatus, s, r.getCount(s))); + } + + content.add(string(R.string.lblMessageStats_wo_total, r.womCount)); + for(WoMessage.Status s : WoMessage.Status.values()) { + content.add(string(R.string.lblMessageStats_forStatus, s, r.getCount(s))); + } + + final AlertDialog.Builder dialog = new AlertDialog.Builder(a); + + dialog.setItems(content.toArray(new String[content.size()]), NO_CLICK_LISTENER); + + showAlert(a, dialog); + } catch(Exception ex) { + logException(a, ex, "Failed to load message stats dialog."); + } finally { + thinking.dismiss(); + } + } + }); + return thinking; + } +} diff --git a/src/main/java/medic/gateway/alert/MmsIntentProcessor.java b/src/main/java/medic/gateway/alert/MmsIntentProcessor.java new file mode 100644 index 0000000..edf7e6f --- /dev/null +++ b/src/main/java/medic/gateway/alert/MmsIntentProcessor.java @@ -0,0 +1,30 @@ +package medic.gateway.alert; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.SmsCompatibility.WAP_PUSH_DELIVER_ACTION; + +public class MmsIntentProcessor extends BroadcastReceiver { + public void onReceive(Context ctx, Intent intent) { + logEvent(ctx, "MmsIntentProcessor.onReceive() :: %s", intent.getAction()); + + try { + switch(intent.getAction()) { + case WAP_PUSH_DELIVER_ACTION: + // We will receive WAP_PUSH_DELIVER_ACTION on Android 4.4+ if set as the + // default SMS application. + // TODO store MMS/WAP Push to the normal inbox + break; + default: + throw new IllegalStateException("Unexpected intent: " + intent); + } + } catch(Exception ex) { + logException(ctx, ex, "MmsIntentProcessor threw exception %s when processing intent: %s", + ex.getClass(), ex.getMessage()); + } + } +} diff --git a/src/main/java/medic/gateway/alert/PromptForPermissionsActivity.java b/src/main/java/medic/gateway/alert/PromptForPermissionsActivity.java new file mode 100644 index 0000000..01fb32e --- /dev/null +++ b/src/main/java/medic/gateway/alert/PromptForPermissionsActivity.java @@ -0,0 +1,163 @@ +package medic.gateway.alert; + +import android.Manifest.permission; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import android.view.View; + +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS; +import static medic.gateway.alert.BuildConfig.IS_MEDIC_FLAVOUR; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.getAppName; +import static medic.gateway.alert.Utils.setText; + +/** + * To support Android 6.0+ (marshmallow), we must request SMS permissions at + * runtime as well as in {@code AndroidManifest.xml}. + * @see https://developer.android.com/intl/ru/about/versions/marshmallow/android-6.0-changes.html#behavior-runtime-permissions + */ +public class PromptForPermissionsActivity extends Activity implements ActivityCompat.OnRequestPermissionsResultCallback { + private static final boolean REFUSE_TO_FUNCTION_WITHOUT_PERMISSIONS = IS_MEDIC_FLAVOUR; + + private static final String X_IS_DEMAND = "isDemand"; + private static final String X_PERMISSIONS_TYPE = "permissionsType"; + + private static final Object[][] PERMISSIONS_REQUESTS = { + /* sms */ { R.string.txtPermissionsPrompt_sms, new String[] { permission.SEND_SMS, permission.RECEIVE_SMS, permission.READ_PHONE_STATE } }, + /* file access */ { R.string.txtPermissionsPrompt_fileAccess, new String[] { permission.WRITE_EXTERNAL_STORAGE } }, + }; + + private boolean isDemand; + private boolean deniedBefore; + private int permissionsRequestType; + + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + isDemand = getIntent().getBooleanExtra(X_IS_DEMAND, false); + + trace(this, "onCreate() :: isDemand=%s, permissionsRequestType=%s", isDemand, permissionsRequestType); + + setContentView(R.layout.prompt_for_permissions); + + int promptTextId; + if(isDemand) { + promptTextId = R.string.txtDemandPermissions; + } else { + permissionsRequestType = getIntent().getIntExtra(X_PERMISSIONS_TYPE, 0); + promptTextId = (int) PERMISSIONS_REQUESTS[permissionsRequestType][0]; + makePermissionRequest(); + } + setText(this, R.id.txtPermissionsPrompt, promptTextId, getAppName(this)); + } + + public void btnOk_onClick(View v) { + if(isDemand) { + // open app manager for this app + Intent i = new Intent(ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", getPackageName(), null)); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(i); + + finish(); + } else makePermissionRequest(); + } + + @SuppressWarnings("PMD.UseVarargs") + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + boolean allGranted = true; + for(int res : grantResults) allGranted &= res == PERMISSION_GRANTED; + + if(allGranted) { + nextActivity(this, permissionsRequestType + 1); + } else if(REFUSE_TO_FUNCTION_WITHOUT_PERMISSIONS) { + // For some flavours, we don't want to give people the option to use the app without the + // correct permissions. If the permission is not granted, re-request the same. + if(canShowPromptFor(this, permissionsRequestType)) { // NOPMD + // Don't do anything - the user can re-read the on-screen advice. + } else { + // The user has checked the "don't ask me again"/"never allow" box (TODO which one?), so we have to step things up. + startActivity(demandPermissions(this)); + finish(); + } + } else { + if(!deniedBefore && canShowPromptFor(this, permissionsRequestType)) { + // Allow user to read the advice on the screen + deniedBefore = true; + } else nextActivity(this, permissionsRequestType + 1); + } + } + +//> PRIVATE HELPERS + private void makePermissionRequest() { + ActivityCompat.requestPermissions(this, getPermissions(permissionsRequestType), 0); + } + +//> STATIC UTILS + static void startPermissionsRequestChain(Activity a) { + nextActivity(a, 0); + } + +//> STATIC HELPERS + private static void nextActivity(Activity a, int firstPermissionToConsider) { + trace(a, "nextActivity() :: %s", firstPermissionToConsider); + + Intent next = null; + + for(int p=firstPermissionToConsider; p EVENT HANDLERS + + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + log("Starting view for PromptToSetAsDefaultMessageAppActivity..."); + + setContentView(R.layout.set_as_default_messaging_app); + String appName = getAppName(this); + setText(this, R.id.txtDefaultMessageAppWarning, R.string.txtDefaultMessageAppWarning, appName); + setText(this, R.id.txtDefaultMessageAppPrompt, R.string.txtDefaultMessageAppPrompt, appName); + } + + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_CHANGE_DEFAULT_MESSAGING_APP: + // we should now know if we're the default SMS app from the value of + // resultCode, but it seems a little odd to trust that result when we + // can just check a method. + if(app.isDefaultSmsProvider(this)) { + continueToSettings(); + } + break; + default: + log("PromptToSetAsDefaultMessageAppActivity :: onActivityResult() :: No handling for requestCode: %s", requestCode); + } + } + +//> CUSTOM EVENT HANDLERS + + public void dismissActivity(View view) { + continueToSettings(); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + @SuppressLint({"ObsoleteSdkInt", "InlinedApi"}) + public void openDefaultMessageAppSettings(View view) { + log("Trying to open SMS Dialog requesting default app. SDK: %s", Build.VERSION.SDK_INT); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startIntentToSetSMSRoleHolder(); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + Intent intent = new Intent(Intents.ACTION_CHANGE_DEFAULT) + .putExtra(Intents.EXTRA_PACKAGE_NAME, getPackageName()); + startActivityForResult(intent, REQUEST_CHANGE_DEFAULT_MESSAGING_APP); + return; + } + } + + @TargetApi(Build.VERSION_CODES.Q) + @SuppressLint({"ObsoleteSdkInt", "InlinedApi"}) + void startIntentToSetSMSRoleHolder() { + RoleManager roleManager = getSystemService(RoleManager.class); + + if (!roleManager.isRoleAvailable(RoleManager.ROLE_SMS)) { + log("SMS Role is not available in the system. Check the phone settings."); + return; + } + + + if (roleManager.isRoleHeld(RoleManager.ROLE_SMS)) { + log("Gateway is already the default app for SMS."); + return; + } + + Intent intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS); + startActivityForResult(intent, REQUEST_CHANGE_DEFAULT_MESSAGING_APP); + } + +//> PRIVATE HELPERS + + private void continueToSettings() { + log("Navigating to Settings View."); + startActivity(new Intent(this, SettingsDialogActivity.class)); + finish(); + } + + private void log(String message, Object... extras) { + trace(this, message, extras); + } +} diff --git a/src/main/java/medic/gateway/alert/Settings.java b/src/main/java/medic/gateway/alert/Settings.java new file mode 100644 index 0000000..abdd08b --- /dev/null +++ b/src/main/java/medic/gateway/alert/Settings.java @@ -0,0 +1,60 @@ +package medic.gateway.alert; + +import android.content.Context; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.SimpleJsonClient2.redactUrl; + +@SuppressWarnings("PMD.ShortMethodName") +public class Settings { + public static final Pattern URL_PATTERN = Pattern.compile( + "http[s]?://([^/:]*)(:\\d*)?(.*)"); + + public static final long POLL_INTERVAL = 30 * 1000L; + + public final String webappUrl; + public final boolean pollingEnabled; + public final boolean cdmaCompatMode; + public final boolean dummySendMode; + + public Settings(String webappUrl, boolean pollingEnabled, boolean cdmaCompatMode, boolean dummySendMode) { + trace(this, "Settings() webappUrl=%s", redactUrl(webappUrl)); + this.webappUrl = webappUrl; + this.pollingEnabled = pollingEnabled; + this.cdmaCompatMode = cdmaCompatMode; + this.dummySendMode = dummySendMode; + } + +//> PUBLIC + public void validate() throws IllegalSettingsException { + if(!pollingEnabled) return; + + List errors = new LinkedList<>(); + + if(!isSet(webappUrl)) { + errors.add(new IllegalSetting("txtWebappUrl:errRequired", + R.id.txtWebappUrl, + R.string.errRequired)); + } else if(!URL_PATTERN.matcher(webappUrl).matches()) { + errors.add(new IllegalSetting("txtWebappUrl:errInvalidUrl:" + webappUrl, + R.id.txtWebappUrl, + R.string.errInvalidUrl)); + } + + if(!errors.isEmpty()) { + throw new IllegalSettingsException(errors); + } + } + +//> PRIVATE HELPERS + private boolean isSet(String val) { + return val != null && val.length() > 0; + } + +//> FACTORIES + public static Settings in(Context ctx) { + return SettingsStore.in(ctx).get(); + } +} diff --git a/src/main/java/medic/gateway/alert/SettingsDialogActivity.java b/src/main/java/medic/gateway/alert/SettingsDialogActivity.java new file mode 100644 index 0000000..62333f1 --- /dev/null +++ b/src/main/java/medic/gateway/alert/SettingsDialogActivity.java @@ -0,0 +1,343 @@ +package medic.gateway.alert; + +import android.app.Activity; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import medic.android.ActivityBackgroundTask; + +import static medic.gateway.alert.BuildConfig.IS_DUMMY_SEND_AVAILABLE; +import static medic.gateway.alert.BuildConfig.IS_MEDIC_FLAVOUR; +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.SimpleJsonClient2.basicAuth_isValidPassword; +import static medic.gateway.alert.SimpleJsonClient2.redactUrl; +import static medic.gateway.alert.Utils.includeVersionNameInActivityTitle; +import static medic.gateway.alert.Utils.startMainActivity; + +@SuppressWarnings({"PMD.GodClass", "PMD.TooManyMethods"}) +public class SettingsDialogActivity extends Activity { + private static final String MEDIC_URL_FORMATTER = "https://gateway:%s@%s.%s.medicmobile.org/api/sms"; + private static final Pattern MEDIC_URL_PARSER = Pattern.compile("https://gateway:([^:]+)@(.+)\\.([^.]+)\\.medicmobile.org/api/sms"); + + private boolean hasPreviousSettings; + private Thinking thinking; + +//> EVENT HANDLERS + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + log("Starting..."); + + includeVersionNameInActivityTitle(this); + + SettingsStore store = SettingsStore.in(this); + hasPreviousSettings = store.hasSettings(); + + setContentView(IS_MEDIC_FLAVOUR ? R.layout.settings_dialog_medic : R.layout.settings_dialog_generic); + + if(IS_DUMMY_SEND_AVAILABLE) addDummySendCheckbox(); + + if(hasPreviousSettings) { + Settings settings = store.get(); + + populateWebappUrlFields(settings.webappUrl); + check(R.id.cbxEnablePolling, settings.pollingEnabled); + check(R.id.cbxEnableCdmaCompatMode, settings.cdmaCompatMode); + + if(IS_DUMMY_SEND_AVAILABLE) + check(R.id.cbxEnableDummySendMode, settings.dummySendMode); + } else { + cancelButton().setVisibility(View.GONE); + } + } + +//> CUSTOM EVENT HANDLERS + public void doSave(View view) { + log("doSave"); + + boolean syncEnabled = checked(R.id.cbxEnablePolling); + + if(syncEnabled) { + boolean hasErrors = requiredFieldsMissing(); + hasErrors |= illegalCharsInTextfields(); + if(hasErrors) return; + } + + submitButton().setEnabled(false); + cancelButton().setEnabled(false); + + if(syncEnabled) { + verifyAndSave(); + } else saveWithoutVerification(); + } + + public void cancelSettingsEdit(View view) { + log("cancelSettingsEdit"); + backToMessageListsView(); + } + + public void onBackPressed() { + if(hasPreviousSettings) { + backToMessageListsView(); + } else { + super.onBackPressed(); + } + } + +//> PRIVATE HELPERS + private boolean requiredFieldsMissing() { + if(IS_MEDIC_FLAVOUR) { + boolean hasBasicErrors = false; + + if(isBlank(R.id.txtWebappInstanceName)) { + showError(R.id.txtWebappInstanceName, R.string.errRequired); + hasBasicErrors = true; + } else if(!text(R.id.txtWebappInstanceName).matches("^[\\w-_]+(\\.[\\w-_]+)*$")) { + showError(R.id.txtWebappInstanceName, R.string.errInvalidInstanceName); + hasBasicErrors = true; + } + + if(isBlank(R.id.txtWebappPassword)) { + showError(R.id.txtWebappPassword, R.string.errRequired); + hasBasicErrors = true; + } + + return hasBasicErrors; + } else { + if(isBlank(R.id.txtWebappUrl)) { + showError(R.id.txtWebappUrl, R.string.errRequired); + return true; + } + return false; + } + } + + private boolean illegalCharsInTextfields() { + if(!IS_MEDIC_FLAVOUR) return false; + + boolean illegalCharsFound = false; + + if(!basicAuth_isValidPassword(text(R.id.txtWebappPassword))) { + showError(R.id.txtWebappPassword, R.string.errPassword_illegalChar); + illegalCharsFound = true; + } + + return illegalCharsFound; + } + + private String getWebappUrlFromFields() { + if(IS_MEDIC_FLAVOUR) { + String instanceName = text(R.id.txtWebappInstanceName); + String subdomain = spinnerVal(R.id.spnWebappSubdomain); + String password = text(R.id.txtWebappPassword); + return String.format(MEDIC_URL_FORMATTER, password, instanceName, subdomain); + } else return text(R.id.txtWebappUrl); + } + + private void populateWebappUrlFields(String appUrl) { + if(IS_MEDIC_FLAVOUR) { + Matcher m = MEDIC_URL_PARSER.matcher(appUrl); + if(m.matches()) { + text(R.id.txtWebappInstanceName, m.group(2)); + spinnerVal(R.id.spnWebappSubdomain, m.group(3)); + text(R.id.txtWebappPassword, m.group(1)); + } else { + trace(this, "URL not being parsed correctly: %s", redactUrl(appUrl)); + } + } else text(R.id.txtWebappUrl, appUrl); + } + + private void backToMessageListsView() { + startMainActivity(this); + finish(); + } + + private void verifyAndSave() { + thinking = Thinking.show(this, + String.format(getString(R.string.txtValidatingWebappUrl), + redactUrl(getWebappUrlFromFields()))); + + new SaveTask(this).execute(); + } + + private void saveWithoutVerification() { + final String webappUrl = getWebappUrlFromFields(); + final boolean cdmaCompatMode = checked(R.id.cbxEnableCdmaCompatMode); + final boolean dummySendMode = isDummySendModeChecked(); + + thinking = Thinking.show(this, + getString(R.string.txtSavingSettings)); + + AsyncTask.execute(new Runnable() { + public void run() { + boolean savedOk = saveSettings(new Settings(webappUrl, false, cdmaCompatMode, dummySendMode)); + + if(savedOk) startApp(); + else { + runOnUiThread(new Runnable() { + public void run() { + submitButton().setEnabled(true); + cancelButton().setEnabled(true); + } + }); + } + thinking.dismiss(); + } + }); + } + + private boolean isDummySendModeChecked() { + return IS_DUMMY_SEND_AVAILABLE ? checked(R.id.cbxEnableDummySendMode) : false; + } + + private boolean saveSettings(Settings s) { + try { + SettingsStore.in(this).save(s); + logEvent(SettingsDialogActivity.this, "Settings saved. Webapp URL: %s", redactUrl(s.webappUrl)); + return true; + } catch(final IllegalSettingsException ex) { + logException(ex, "SettingsDialogActivity.saveSettings()"); + runOnUiThread(new Runnable() { + public void run() { + for(IllegalSetting error : ex.errors) { + showError(error); + } + } + }); + return false; + } catch(final SettingsException ex) { + logException(ex, "SettingsDialogActivity.saveSettings()"); + runOnUiThread(new Runnable() { + public void run() { + submitButton().setError(ex.getMessage()); + } + }); + return false; + } + } + + private void handleSaveResult(WebappUrlVerififcation result) { + boolean cdmaCompatMode = checked(R.id.cbxEnableCdmaCompatMode); + boolean dummySendMode = isDummySendModeChecked(); + + boolean savedOk = false; + + if(result.isOk) + savedOk = saveSettings(new Settings(result.webappUrl, true, cdmaCompatMode, dummySendMode)); + else + showError(IS_MEDIC_FLAVOUR ? R.id.txtWebappInstanceName : R.id.txtWebappUrl, result.failure); + + if(savedOk) startApp(); + else { + submitButton().setEnabled(true); + cancelButton().setEnabled(true); + } + thinking.dismiss(); + } + + private void startApp() { + startMainActivity(this); + finish(); + } + + private void addDummySendCheckbox() { + View prev = findViewById(R.id.cbxEnableCdmaCompatMode); + ViewGroup container = (ViewGroup) prev.getParent(); + + View cbx = LayoutInflater.from(this).inflate(R.layout.cbx_dummy_send_mode, container, false); + + int insertIdx = 1 + container.indexOfChild(prev); + container.addView(cbx, insertIdx); + } + + private Button cancelButton() { + return (Button) findViewById(R.id.btnCancelSettings); + } + + private Button submitButton() { + return (Button) findViewById(R.id.btnSaveSettings); + } + + private boolean checked(int componentId) { + CheckBox field = (CheckBox) findViewById(componentId); + return field.isChecked(); + } + + private void check(int componentId, boolean checked) { + CheckBox field = (CheckBox) findViewById(componentId); + field.setChecked(checked); + } + + private String text(int componentId) { + EditText field = (EditText) findViewById(componentId); + return field.getText().toString(); + } + + private void text(int componentId, String value) { + EditText field = (EditText) findViewById(componentId); + field.setText(value); + } + + private boolean isBlank(int componentId) { + return text(componentId).length() == 0; + } + + private String spinnerVal(int componentId) { + return ((Spinner) findViewById(componentId)).getSelectedItem().toString(); + } + + private void spinnerVal(int componentId, String val) { + Spinner spinner = (Spinner) findViewById(componentId); + for(int i=spinner.getCount()-1; i>=0; --i) { + if(val.equals(spinner.getItemAtPosition(i).toString())) { + spinner.setSelection(i); + return; + } + } + } + + private void showError(IllegalSetting error) { + showError(error.componentId, error.errorStringId); + } + + private void showError(int componentId, int stringId) { + TextView field = (TextView) findViewById(componentId); + field.setError(getString(stringId)); + } + + private void log(String message, Object... extras) { + trace(this, message, extras); + } + + + private static class SaveTask extends ActivityBackgroundTask { + SaveTask(SettingsDialogActivity ctx) { + super(ctx); + } + + protected WebappUrlVerififcation doInBackground(Void... v) { + SettingsDialogActivity a = getRequiredCtx("SaveTask.doInBackground()"); + String webappUrl = a.getWebappUrlFromFields(); + return new WebappUrlVerifier(a).verify(webappUrl); + } + protected void onPostExecute(WebappUrlVerififcation result) { + SettingsDialogActivity a = getCtx(); + + if(a == null) throw new IllegalStateException("SaveTask.doInBackground() :: no parent context available."); + + a.handleSaveResult(result); + } + } +} diff --git a/src/main/java/medic/gateway/alert/SettingsStore.java b/src/main/java/medic/gateway/alert/SettingsStore.java new file mode 100644 index 0000000..b19fca6 --- /dev/null +++ b/src/main/java/medic/gateway/alert/SettingsStore.java @@ -0,0 +1,97 @@ +package medic.gateway.alert; + +import android.content.Context; +import android.content.SharedPreferences; +import java.util.List; +import static medic.gateway.alert.BuildConfig.DEBUG; +import static medic.gateway.alert.GatewayLog.trace; + +@SuppressWarnings("PMD.ShortMethodName") +public class SettingsStore { + private final SharedPreferences prefs; + + SettingsStore(SharedPreferences prefs) { + this.prefs = prefs; + } + +//> ACCESSORS + public Settings get() { + Settings s = new Settings( + prefs.getString("app-url", null), + prefs.getBoolean("polling-enabled", true), + prefs.getBoolean("cdma-compat-enabled", false), + prefs.getBoolean("dummy-send-enabled", false)); + + try { + s.validate(); + } catch(IllegalSettingsException ex) { + return null; + } + return s; + } + + public boolean hasSettings() { + return get() != null; + } + + public void save(Settings s) throws SettingsException { + s.validate(); + + SharedPreferences.Editor ed = prefs.edit(); + ed.putString("app-url", s.webappUrl); + ed.putBoolean("polling-enabled", s.pollingEnabled); + ed.putBoolean("cdma-compat-enabled", s.cdmaCompatMode); + ed.putBoolean("dummy-send-enabled", s.dummySendMode); + if(!ed.commit()) throw new SettingsException( + "Failed to save to SharedPreferences."); + } + + public static SettingsStore in(Context ctx) { + trace(SettingsStore.class, "loading for context: %s", ctx); + + SharedPreferences prefs = ctx.getSharedPreferences( + SettingsStore.class.getName(), + Context.MODE_PRIVATE); + + return new SettingsStore(prefs); + } +} + +class IllegalSetting { + public final String description; + public final int componentId; + public final int errorStringId; + + public IllegalSetting(String description, int componentId, int errorStringId) { + this.description = description; + this.componentId = componentId; + this.errorStringId = errorStringId; + } +} + +class SettingsException extends Exception { + public SettingsException(String message) { + super(message); + } +} + +class IllegalSettingsException extends SettingsException { + public final List errors; + + public IllegalSettingsException(List errors) { + super(createMessage(errors)); + this.errors = errors; + } + + private static String createMessage(List errors) { + if(DEBUG) { + StringBuilder bob = new StringBuilder(); + for(IllegalSetting e : errors) { + if(bob.length() > 0) bob.append("; "); + bob.append(e.description); + } + return bob.toString(); + } + return null; + } +} diff --git a/src/main/java/medic/gateway/alert/SimpleJsonClient2.java b/src/main/java/medic/gateway/alert/SimpleJsonClient2.java new file mode 100644 index 0000000..f04dce2 --- /dev/null +++ b/src/main/java/medic/gateway/alert/SimpleJsonClient2.java @@ -0,0 +1,318 @@ +package medic.gateway.alert; + +import android.net.Uri; +import android.util.Base64; +import android.util.Log; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import java.nio.charset.Charset; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONException; +import org.json.JSONObject; + +import static medic.gateway.alert.BuildConfig.DEBUG; +import static medic.gateway.alert.BuildConfig.LOG_TAG; + +/** + *

New and improved - SimpleJsonClient2 is SimpleJsonClient, but using + * HttpURLConnection instead of DefaultHttpClient. + *

SimpleJsonClient2 should be used in preference to SimpleJsonClient on + * Android 2.3 (API level 9/Gingerbread) and above. + * @see java.net.HttpURLConnection + * @see org.apache.http.impl.client.DefaultHttpClient + */ +@SuppressWarnings("PMD.GodClass") +public class SimpleJsonClient2 { + private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + private static final Pattern AUTH_URL = Pattern.compile("(.+)://([^:]*):(.*)@(.*)"); + +//> PUBLIC METHODS + public SimpleResponse get(String url) throws MalformedURLException { + if(DEBUG) traceMethod("get", "url", redactUrl(url)); + return get(new URL(url)); + } + + public SimpleResponse get(URL url) { + if(DEBUG) traceMethod("get", "url", redactUrl(url)); + HttpURLConnection conn = null; + try { + conn = openConnection(url); + conn.setRequestProperty("Content-Type", "application/json"); + + return responseFrom("get", conn); + } catch(IOException | JSONException ex) { + return exceptionResponseFor(conn, ex); + } finally { + closeSafely("get", conn); + } + } + + public SimpleResponse post(String url, JSONObject content) throws MalformedURLException { + if(DEBUG) traceMethod("post", "url", redactUrl(url)); + return post(new URL(url), content); + } + + public SimpleResponse post(URL url, JSONObject content) { + if(DEBUG) traceMethod("post", "url", redactUrl(url)); + HttpURLConnection conn = null; + OutputStream outputStream = null; + try { + conn = openConnection(url); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Charset", "utf-8"); + conn.setRequestProperty("Cache-Control", "no-cache"); + conn.setRequestProperty("Content-Type", "application/json"); + + outputStream = conn.getOutputStream(); + outputStream.write(content.toString().getBytes("UTF-8")); + + return responseFrom("post", conn); + } catch(IOException | JSONException ex) { + return exceptionResponseFor(conn, ex); + } finally { + closeSafely("post", outputStream); + closeSafely("post", conn); + } + } + +//> PUBLIC UTILS + public static String redactUrl(URL url) { + return redactUrl(url.toString()); + } + public static String redactUrl(String url) { + if(url == null) return null; + + Matcher m = AUTH_URL.matcher(url); + if(!m.matches()) return url; + + return String.format("%s://%s:%s@%s", + m.group(1), m.group(2), "****", m.group(4)); + } + + public static boolean basicAuth_isValidUsername(String username) { + for(int i=username.length()-1; i>=0; --i) { + switch(username.charAt(i)) { + case '#': case '/': case '?': case '@': case ':': + return false; + } + } + return basicAuth_isValidPassword(username); + } + + public static boolean basicAuth_isValidPassword(String password) { + String reEncoded = new String(password.getBytes(ISO_8859_1), ISO_8859_1); + return password.equals(reEncoded); + } + + public static String uriEncodeAuth(String url) { + if(url == null) return null; + + Matcher m = AUTH_URL.matcher(url); + if(!m.matches()) return url; + + return String.format("%s://%s:%s@%s", + m.group(1), m.group(2), Uri.encode(m.group(3)), m.group(4)); + } + +//> INSTANCE HELPERS + @SuppressFBWarnings("NP_LOAD_OF_KNOWN_NULL_VALUE") // for closeSafely() + private SimpleResponse responseFrom(String method, HttpURLConnection conn) throws IOException, JSONException { + int status = conn.getResponseCode(); + + InputStream inputStream = null; + try { + if(status < 400) { + inputStream = conn.getInputStream(); + return new JsonResponse(status, readStream(method, inputStream)); + } else { + inputStream = conn.getErrorStream(); + + if(inputStream == null) return new EmptyResponse(status); + + CharSequence responseBody = readStream(method, inputStream); + try { + return new JsonResponse(status, responseBody); + } catch(JSONException ex) { + return new TextResponse(status, responseBody); + } + } + } finally { + closeSafely(method, inputStream); + } + } + + private CharSequence readStream(String method, InputStream in) throws IOException { + BufferedReader reader = null; + try { + reader = new BufferedReader(new InputStreamReader(in, "UTF-8"), 8); + StringBuilder bob = new StringBuilder(); + + String line = null; + while((line = reader.readLine()) != null) { + bob.append(line).append('\n'); + } + + if(DEBUG) log(method, "Retrieved text: %s", bob); + + return bob; + } finally { + closeSafely(method, reader); + } + } + + private ExceptionResponse exceptionResponseFor(HttpURLConnection conn, Exception ex) { + int responseCode = -1; + try { + responseCode = conn.getResponseCode(); + } catch(Exception ignore) {} // NOPMD + return new ExceptionResponse(responseCode, ex); + } + + private void closeSafely(String method, Closeable c) { + if(c != null) try { + c.close(); + } catch(Exception ex) { + if(DEBUG) log(ex, "SimpleJsonClient2.%s()", method); + } + } + + private void closeSafely(String method, HttpURLConnection conn) { + if(conn != null) try { + conn.disconnect(); + } catch(Exception ex) { + if(DEBUG) log(ex, "SimpleJsonClient2.%s()", method); + } + } + +//> STATIC HELPERS + @SuppressWarnings("PMD.PreserveStackTrace") + private static HttpURLConnection openConnection(URL url) throws IOException { + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + + String userAgent = String.format("%s %s/%s", + System.getProperty("http.agent"), + BuildConfig.APPLICATION_ID, + BuildConfig.VERSION_NAME); + conn.setRequestProperty("User-Agent", userAgent); + + if(url.getUserInfo() != null) { + try { + conn.setRequestProperty("Authorization", "Basic " + encodeCredentials(url.getUserInfo())); + } catch(Exception ex) { + // Don't include exception details in case they include auth details + throw new RuntimeException(String.format("%s caught while setting Authorization header.", ex.getClass())); + } + } + return conn; + } + + /** + * Base64-encode the {@code user-pass} component of HTTP {@code Authorization: Basic} header. + * @see https://tools.ietf.org/html/rfc2617#section-2 + */ + @SuppressWarnings("PMD.PreserveStackTrace") + private static String encodeCredentials(String normal) { + return Base64.encodeToString(normal.getBytes(ISO_8859_1), Base64.NO_WRAP); + } + + private static void traceMethod(String methodName, Object...args) { + StringBuilder bob = new StringBuilder(); + for(int i=0; i M is unnecessary: "Error: Unnecessary; SDK_INT is never < 16", but I think M is version 23 + public static SmsMessage createFromPdu(Intent intent) { + byte[] pdu = intent.getByteArrayExtra("pdu"); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + String format = intent.getStringExtra("format"); + return SmsMessage.createFromPdu(pdu, format); + } else { + return SmsMessage.createFromPdu(pdu); + } + } +} diff --git a/src/main/java/medic/gateway/alert/SmsSender.java b/src/main/java/medic/gateway/alert/SmsSender.java new file mode 100644 index 0000000..52aeceb --- /dev/null +++ b/src/main/java/medic/gateway/alert/SmsSender.java @@ -0,0 +1,202 @@ +package medic.gateway.alert; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.telephony.SmsManager; + +import java.util.ArrayList; +import java.util.List; + +import static android.telephony.PhoneNumberUtils.isGlobalPhoneNumber; +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.IntentProcessor.DELIVERY_REPORT; +import static medic.gateway.alert.IntentProcessor.SENDING_REPORT; +import static medic.gateway.alert.WoMessage.Status.DELIVERED; +import static medic.gateway.alert.WoMessage.Status.PENDING; +import static medic.gateway.alert.WoMessage.Status.SENT; +import static medic.gateway.alert.WoMessage.Status.UNSENT; + +@SuppressWarnings("PMD.LooseCoupling") +public class SmsSender { + private static final int MAX_WO_MESSAGES = 10; + private static final String DEFAULT_SMSC = null; + + private final Context ctx; + private final Db db; + private final SmsManager smsManager; + + /** + * Some CDMA networks do not support multipart SMS properly. On these + * networks, we just divide the messages ourselves and send them as + * multiple individual messages. + * {@code true} if the user has enabled CDMA Compatibility Mode in settings. + */ + private final boolean cdmaCompatMode; + + /** + * To aid testing of systems dealing with large numbers of messages, you + * can enable "dummy send" mode, which will immediately set all outgoing + * messages as SENT, instead of actually sending them. + */ + private final boolean dummySendMode; + + public SmsSender(Context ctx) { + this.ctx = ctx; + this.db = Db.getInstance(ctx); + this.smsManager = SmsManager.getDefault(); + + Settings settings = Settings.in(ctx); + this.cdmaCompatMode = settings == null ? false : settings.cdmaCompatMode; + this.dummySendMode = settings == null ? false : settings.dummySendMode; + } + + public void sendUnsentSmses() { + List smsForSending = this.getUnsentMessages(); + + if(smsForSending.isEmpty()) { + logEvent(ctx, "No SMS waiting to be sent."); + } else { + logEvent(ctx, "Sending %d SMSs...", smsForSending.size()); + + for(WoMessage m : smsForSending) { + try { + trace(this, "sendUnsentSmses() :: attempting to send %s", m); + if(dummySendMode) sendSms_dummy(m); + else sendSms(m); + } catch(Exception ex) { + logException(ex, "SmsSender.sendUnsentSmses() :: message=%s", m); + db.setFailed(m, String.format("Exception: %s; message: %s; cause: %s", + ex, ex.getMessage(), ex.getCause())); + } + } + } + } + + private void sendSms(WoMessage m) { + logEvent(ctx, "sendSms() :: [%s] '%s'", m.to, m.content); + + boolean statusUpdated = db.updateStatus(m, UNSENT, PENDING); + if(statusUpdated) { + if(isGlobalPhoneNumber(m.to)) { + if(cdmaCompatMode) { + ArrayList parts = divideMessageForCdma(m.content); + int totalParts = parts.size(); + for(int partIndex=0; partIndex parts = smsManager.divideMessage(m.content); + smsManager.sendMultipartTextMessage( + m.to, + DEFAULT_SMSC, + parts, + intentsFor(SENDING_REPORT, m, parts), + intentsFor(DELIVERY_REPORT, m, parts)); + } + } else { + logEvent(ctx, "Not sending SMS to '%s' because number appears invalid (content: '%s')", + m.to, m.content); + db.setFailed(m, "destination.invalid"); + } + } + } + + private void sendSms_dummy(WoMessage m) { + logEvent(ctx, "sendSms_dummy() :: [%s] '%s'", m.to, m.content); + db.updateStatus(m, UNSENT, PENDING); + db.updateStatus(m, PENDING, SENT); + db.updateStatus(m, SENT, DELIVERED); + } + + private ArrayList intentsFor(String intentType, WoMessage m, ArrayList parts) { + int totalParts = parts.size(); + ArrayList intents = new ArrayList<>(totalParts); + for(int partIndex=0; partIndex divideMessageForCdma(String content) { + ArrayList parts = new ArrayList<>(); + + int perMessageCharLimit = onlyExtendedAscii(content) ? 140 : 70; + + if(content.length() <= perMessageCharLimit) { + parts.add(content); + } else { + // Leave space for the `n/N ` part indicator. + perMessageCharLimit -= 4; + + // This code could save 9 characters for messages with 10 parts or more, and + // more for messages with 100 or more parts, but it doesn't seem worth the + // effort handling these cases. Also, if there are more than 999 parts we'll + // be in trouble. + int partCount = content.length() / perMessageCharLimit; + if(partCount >= 10) partCount = content.length() / --perMessageCharLimit; + if(partCount >= 100) partCount = content.length() / --perMessageCharLimit; + + for(int i=0; i=0; --i) + if(s.charAt(i) > 255) return false; + return true; + } + + private List getUnsentMessages() { + List unsentSms = db.getWoMessages(MAX_WO_MESSAGES, UNSENT); + List smsForSending = new ArrayList<>(); + + for(WoMessage sms : unsentSms) { + if (sms.retries > 0) { + if (sms.canRetryAfterSoftFail()) { + smsForSending.add(sms); + } + } else { + smsForSending.add(sms); + } + } + + return smsForSending; + } +} diff --git a/src/main/java/medic/gateway/alert/SmsUdh.java b/src/main/java/medic/gateway/alert/SmsUdh.java new file mode 100644 index 0000000..750f17a --- /dev/null +++ b/src/main/java/medic/gateway/alert/SmsUdh.java @@ -0,0 +1,106 @@ +package medic.gateway.alert; + +import android.telephony.SmsMessage; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.util.Arrays; + +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.GatewayLog.logException; + +@SuppressWarnings({ "PMD.StdCyclomaticComplexity", "PMD.ModifiedCyclomaticComplexity" }) +final class SmsUdh { + private static final int TYPE_CONCAT_8_BIT = 0x00; + private static final int TYPE_CONCAT_16_BIT = 0x08; + + final int multipartRef; + final int partNumber; + final int totalParts; + final long sentTimestamp; + + private SmsUdh(int multipartRef, int totalParts, int partNumber, long sentTimestamp) throws EOFException { + if(multipartRef == -1 || partNumber == -1 || totalParts == -1) throw new EOFException(); + this.multipartRef = multipartRef; + this.totalParts = totalParts; + this.partNumber = partNumber; + this.sentTimestamp = sentTimestamp; + } + + /** + * There's a good chance this method will not work correctly for CDMA + * messages. If that turns out to be the case, it should switch on + * {@code TelephonyManager.getDefault().getCurrentPhoneType()}, + * comparing values to + * {@code com.android.internal.telephony.SmsConstants.FORMAT_*}. + */ + public static SmsUdh from(SmsMessage m) { + byte[] pdu = m.getPdu(); + if(pdu == null || pdu.length == 0) return null; + + trace(SmsUdh.class, "from() :: pdu=%s", Arrays.toString(pdu)); + + ByteArrayInputStream bais = null; + DataInputStream in = null; + try { + bais = new ByteArrayInputStream(pdu); + in = new DataInputStream(bais); + + // skip SMSC field + int smscLen = in.read(); + while(--smscLen >= 0) in.read(); + + int byte0 = in.read(); + + // check if UDH is present + if((byte0 & (1 << 6)) == 0) return null; + + // skip FROM phone number + int fromLength = in.read(); + int fromBytes = (fromLength >> 1) + (fromLength & 1) + 1; + while(--fromBytes >= 0) in.read(); + + // skip PID; skip DCS + in.read(); in.read(); + + // TODO process timestamp + long sentTimestamp = 0; + in.read(); in.read(); in.read(); in.read(); in.read(); in.read(); in.read(); + + // skip UD length byte + in.read(); + + int bytesRemaining = in.read(); + while(bytesRemaining > 0) { + bytesRemaining -= 2; + final int elementTypeId = in.read(); + int elementContentLength = in.read(); + bytesRemaining -= elementContentLength; + + switch(elementTypeId) { + case TYPE_CONCAT_8_BIT: + return new SmsUdh(in.read(), in.read(), in.read(), sentTimestamp); + case TYPE_CONCAT_16_BIT: + return new SmsUdh(in.readUnsignedShort(), in.read(), in.read(), sentTimestamp); + case -1: + throw new EOFException(); + default: + // Irrelevant UDH part - discard + trace(SmsUdh.class, "from() :: Unrecognised UDH element. Type ID: %s", elementTypeId); + while(--elementContentLength >= 0) in.read(); + } + } + + // No multipart header found + return null; + } catch(IOException ex) { + logException(ex, "Exception decoding UDH. UDH will be ignored."); + return null; + } finally { + if(in != null) try { in.close(); } catch(Exception ex) { logException(ex, "Error closing DataInputStream."); } + if(bais != null) try { bais.close(); } catch(Exception ex) { logException(ex, "Error closing ByteArrayInputStream."); } + } + } +} diff --git a/src/main/java/medic/gateway/alert/StartupActivity.java b/src/main/java/medic/gateway/alert/StartupActivity.java new file mode 100644 index 0000000..a40c9aa --- /dev/null +++ b/src/main/java/medic/gateway/alert/StartupActivity.java @@ -0,0 +1,18 @@ +package medic.gateway.alert; + +import android.app.Activity; +import android.os.Bundle; + +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.PromptForPermissionsActivity.startPermissionsRequestChain; + +public class StartupActivity extends Activity { + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + trace(this, "Starting..."); + + startPermissionsRequestChain(this); + + finish(); + } +} diff --git a/src/main/java/medic/gateway/alert/Thinking.java b/src/main/java/medic/gateway/alert/Thinking.java new file mode 100644 index 0000000..6b36e37 --- /dev/null +++ b/src/main/java/medic/gateway/alert/Thinking.java @@ -0,0 +1,47 @@ +package medic.gateway.alert; + +import android.app.ProgressDialog; +import android.content.Context; + +/** + * Handle the lifecycle of {@code ProgressDialog}s. These need a little bit of + * care to make sure that: + * + * 1. they are dismissed when the parent {@code Context} is destroyed, and + * 2. they are not dismissed when they are not being displayed + * + * If you create an instance of this class, be careful to call `.dismiss()` when + * the parent {@code Context}'s {@code onDestroy()} method is called. + */ +final class Thinking { + private final ProgressDialog dialog; + + private Thinking(ProgressDialog dialog) { + this.dialog = dialog; + } + + public void dismiss() { + if(dialog.isShowing()) dialog.dismiss(); + } + +//> FACTORIES + static Thinking show(Context ctx) { + return show(ctx, null); + } + + static Thinking show(Context ctx, int messageId) { + return show(ctx, ctx.getString(messageId)); + } + + static Thinking show(Context ctx, String message) { + ProgressDialog p = new ProgressDialog(ctx); + p.setProgressStyle(ProgressDialog.STYLE_SPINNER); + if(message != null) p.setMessage(message); + p.setIndeterminate(true); + p.setCanceledOnTouchOutside(false); + + p.show(); + + return new Thinking(p); + } +} diff --git a/src/main/java/medic/gateway/alert/Utils.java b/src/main/java/medic/gateway/alert/Utils.java new file mode 100644 index 0000000..8e5fa74 --- /dev/null +++ b/src/main/java/medic/gateway/alert/Utils.java @@ -0,0 +1,197 @@ +package medic.gateway.alert; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.AsyncTask; +import android.text.Html; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.Date; +import java.text.SimpleDateFormat; + +import org.json.JSONException; +import org.json.JSONObject; + +import static java.util.UUID.randomUUID; + +import static medic.gateway.alert.BuildConfig.DEBUG; +import static medic.gateway.alert.Capabilities.getCapabilities; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; + +@SuppressWarnings({"PMD.ModifiedCyclomaticComplexity", + "PMD.NPathComplexity", + "PMD.StdCyclomaticComplexity"}) +public final class Utils { + private static final long ONE_MINUTE = 1000 * 60; + private static final long ONE_HOUR = ONE_MINUTE * 60; + private static final long ONE_DAY = ONE_HOUR * 24; + + private static final long TWO_DAYS = ONE_DAY * 2; + private static final long ONE_WEEK = ONE_DAY * 7; + private static final long ONE_MONTH = ONE_WEEK * 4; + private static final long ONE_YEAR = ONE_MONTH * 12; + + public static final DialogInterface.OnClickListener NO_CLICK_LISTENER = null; + + private Utils() {} + + public static String getAppName(Context ctx) { + return ctx.getResources().getString(R.string.app_name); + } + + public static String getAppVersion(Context ctx) { + try { + return ctx.getPackageManager() + .getPackageInfo(ctx.getPackageName(), 0) + .versionName; + } catch(Exception ex) { + return "?"; + } + } + + public static String normalisePhoneNumber(String phoneNumber) { + return phoneNumber.replaceAll("[-\\s]", ""); + } + + public static String randomUuid() { + return randomUUID().toString(); + } + + public static void toast(Context ctx, int messageId, Object... args) { + toast(ctx, ctx.getString(messageId), args); + } + + public static void toast(Context ctx, String message, Object... args) { + Toast.makeText(ctx, String.format(message, args), Toast.LENGTH_LONG).show(); + } + + public static void showAlert(final Activity parent, final AlertDialog.Builder dialog) { + parent.runOnUiThread(new Runnable() { + public void run() { + if(parent.isFinishing()) return; + dialog.create().show(); + } + }); + } + + public static JSONObject json(Object... keyVals) throws JSONException { + if(DEBUG && keyVals.length % 2 != 0) throw new AssertionError(); + JSONObject o = new JSONObject(); + for(int i=keyVals.length-1; i>0; i-=2) { + o.put(keyVals[i-1].toString(), keyVals[i]); + } + return o; + } + + public static String absoluteTimestamp(long timestamp) { + return SimpleDateFormat.getDateTimeInstance() + .format(new Date(timestamp)); + } + + public static String relativeTimestamp(long timestamp) { + long diff = System.currentTimeMillis() - timestamp; + + if(diff < ONE_MINUTE) { + return "just now"; + } + + if(diff < ONE_HOUR) { + long mins = diff / ONE_MINUTE; + return mins + "m ago"; + } + + if(diff < ONE_DAY) { + long hours = diff / ONE_HOUR; + return hours + "h ago"; + } + + if(diff < TWO_DAYS) return "yesterday"; + + if(diff < ONE_WEEK) { + long days = diff / ONE_DAY; + return days + " days ago"; + } + + if(diff < ONE_MONTH) { + long weeks = diff / ONE_WEEK; + if(weeks == 1) return "a week ago"; + return weeks + " weeks ago"; + } + + if(diff < ONE_YEAR) { + long months = diff / ONE_MONTH; + if(months == 1) return "a month ago"; + return months + " months ago"; + } + + long years = diff / ONE_YEAR; + if(years == 1) return "a year ago"; + return years + " years ago"; + } + + public static String[] args(String... args) { + return args; + } + + public static String[] args(Object... args) { + String[] strings = new String[args.length]; + for(int i=args.length-1; i>=0; --i) { + strings[i] = args[i] == null? null: args[i].toString(); + } + return strings; + } + + public static void startSettingsActivity(Context ctx, Capabilities app) { + Class activity; + if(app.canBeDefaultSmsProvider() && !app.isDefaultSmsProvider(ctx)) { + activity = PromptToSetAsDefaultMessageAppActivity.class; + } else { + activity = SettingsDialogActivity.class; + } + ctx.startActivity(new Intent(ctx, activity)); + } + + public static void setText(View v, int textViewId, String text) { + TextView tv = (TextView) v.findViewById(textViewId); + tv.setText(text); + } + + public static void setText(Activity a, int textViewId, int stringId, Object... args) { + TextView text = (TextView) a.findViewById(textViewId); + text.setText(Html.fromHtml(a.getResources().getString(stringId, args))); + } + + public static void startMainActivity(final Context ctx) { + AsyncTask.execute(new Runnable() { + public void run() { + AlarmListener.restart(ctx); + } + }); + ctx.startActivity(new Intent(ctx, MessageListsActivity.class)); + } + + public static void startSettingsOrMainActivity(Context ctx) { + if(SettingsStore.in(ctx).hasSettings()) { + trace(ctx, "Starting MessageListsActivity..."); + startMainActivity(ctx); + } else { + trace(ctx, "Starting settings activity..."); + startSettingsActivity(ctx, getCapabilities()); + } + } + + public static void includeVersionNameInActivityTitle(Activity a) { + try { + String versionName = a.getPackageManager().getPackageInfo(a.getPackageName(), 0).versionName; + a.setTitle(a.getTitle() + " " + versionName); + } catch(Exception ex) { + logException(ex, "Could not include the version number in the page title."); + } + } +} diff --git a/src/main/java/medic/gateway/alert/WakefulService.java b/src/main/java/medic/gateway/alert/WakefulService.java new file mode 100644 index 0000000..2d86888 --- /dev/null +++ b/src/main/java/medic/gateway/alert/WakefulService.java @@ -0,0 +1,70 @@ +package medic.gateway.alert; + +import android.content.Context; +import android.content.Intent; + +import com.commonsware.cwac.wakeful.WakefulIntentService; + +import static medic.gateway.alert.GatewayLog.logException; + +// TODO: WakefulIntentService is dead and should be replaced with official code in +// Android Jetpack. See: https://github.com/commonsguy/cwac-wakeful +public class WakefulService extends WakefulIntentService { + private final Context ctx; + + public WakefulService() { + super("WakefulService"); + + this.ctx = this; + } + + WakefulService(Context ctx) { + super("WakefulService"); + + this.ctx = ctx; + } + + @SuppressWarnings({ "PMD.AvoidDeeplyNestedIfStmts", "PMD.StdCyclomaticComplexity", "PMD.ModifiedCyclomaticComplexity", "PMD.NPathComplexity" }) + public void doWakefulWork(Intent intent) { + try { + Db.getInstance(ctx).cleanLogs(); + } catch (Exception ex) { + logException(ctx, ex, "Exception caught trying to clean up event log: %s", ex.getMessage()); + } + + try { + WebappPoller poller; + boolean keepPollingWebapp = true; + + while (keepPollingWebapp) { + poller = new WebappPoller(ctx); + SimpleResponse lastResponse = poller.pollWebapp(); + + if (lastResponse == null || lastResponse.isError()) { + LastPoll.failed(ctx); + } else { + LastPoll.succeeded(ctx); + } + + /** + * NB: this is only testing that *we* have more to send to webapp, and not that webapp has + * more to send to us. To enable that feature correctly we should have webapp pass us this + * value back, because otherwise we'd have to hardcode webapp's batch size in Gateway + */ + keepPollingWebapp = poller.moreMessagesToSend(lastResponse); + } + + } catch (Exception ex) { + logException(ctx, ex, "Exception caught trying to poll webapp: %s", ex.getMessage()); + LastPoll.failed(ctx); + } finally { + LastPoll.broadcast(ctx); + } + + try { + new SmsSender(ctx).sendUnsentSmses(); + } catch (Exception ex) { + logException(ctx, ex, "Exception caught trying to send SMSes: %s", ex.getMessage()); + } + } +} diff --git a/src/main/java/medic/gateway/alert/WebappPoller.java b/src/main/java/medic/gateway/alert/WebappPoller.java new file mode 100644 index 0000000..d54997c --- /dev/null +++ b/src/main/java/medic/gateway/alert/WebappPoller.java @@ -0,0 +1,278 @@ +package medic.gateway.alert; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import java.net.MalformedURLException; +import java.util.List; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import static medic.gateway.alert.BuildConfig.DEBUG; +import static medic.gateway.alert.GatewayLog.logEvent; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.SimpleJsonClient2.uriEncodeAuth; +import static medic.gateway.alert.Utils.normalisePhoneNumber; +import static medic.gateway.alert.Utils.json; + +public class WebappPoller { + private static final int MAX_WT_MESSAGES = 10; + private static final int MAX_WOM_STATUS_UPDATES = 20; + + private final Context ctx; + private final Db db; + + private final GatewayRequest request; + private final String webappUrl; + + public WebappPoller(Context ctx) { + this.ctx = ctx; + db = Db.getInstance(ctx); + + request = new GatewayRequest( + db.getWtMessages(MAX_WT_MESSAGES, WtMessage.Status.WAITING), + db.getWoMessageStatusUpdates(MAX_WOM_STATUS_UPDATES) + ); + + webappUrl = Settings.in(ctx).webappUrl; + } + +//> PUBLIC API + public SimpleResponse pollWebapp() throws JSONException, MalformedURLException { + logEvent(ctx, "Polling webapp (forwarding %d messages & %d status updates)...", + request.wtMessageCount(), request.statusUpdateCount()); + + SimpleResponse response = new SimpleJsonClient2() + .post(uriEncodeAuth(webappUrl), request.getJson()); + + if (DEBUG) { + log(response.toString()); + } + + if (response.isError()) { + handleError(response); + } else { + handleOkResponse(request, ((JsonResponse) response).json); + } + + return response; + } + + public Boolean moreMessagesToSend(SimpleResponse lastResponse) { + if (lastResponse == null || lastResponse.isError()) { + return false; + } + + return request.wtMessageCount() == MAX_WT_MESSAGES || request.statusUpdateCount() == MAX_WOM_STATUS_UPDATES; + } + +//> PRIVATE HELPERS + private void handleOkResponse(GatewayRequest request, JSONObject response) throws JSONException { + for (WtMessage m : request.messages) { + try { + db.updateStatusFrom(WtMessage.Status.WAITING, m); + } catch (Exception ex) { + logException(ctx, ex, "WebappPoller::Error updating WT message %s status: %s", m.id, ex.getMessage()); + } + } + + for (WoMessage.StatusUpdate u : request.statusUpdates) { + try { + db.setStatusForwarded(u); + } catch (Exception ex) { + logException(ctx, ex, "WebappPoller::Error updating WO message status %s as forwarded: %s", u, ex.getMessage()); + } + } + + if (response.isNull("messages")) { + return; + } + + JSONArray messages = response.getJSONArray("messages"); + + logEvent(ctx, "Received %d SMS from server for sending.", messages.length()); + + for (int i=0; i < messages.length(); ++i) { + try { + saveMessage(messages.getJSONObject(i)); + } catch (Exception ex) { + logException(ex, "WebappPoller.handleOkResponse()"); + } + } + } + + private void handleError(SimpleResponse response) throws JSONException { + CharSequence description = "unknown"; + + if (response instanceof JsonResponse) { + JsonResponse jsonResponse = (JsonResponse) response; + + if (jsonResponse.json.has("message")) { + description = jsonResponse.json.getString("message"); + } + + } else if (response instanceof ExceptionResponse) { + description = ((ExceptionResponse) response).ex.toString(); + + } else if (response instanceof TextResponse) { + description = ((TextResponse) response).text; + } + + logEvent(ctx, "Received error from server: %s: %s", response.status, description); + } + + private void saveMessage(JSONObject json) throws JSONException { + logEvent(ctx, "Saving WO message: %s", json); + WoMessage m = new WoMessage( + json.getString("id"), + normalisePhoneNumber(json.getString("to")), + json.getString("content") + ); + + boolean success = db.store(m); + + if (!success) { + logEvent(ctx, "Failed to save WO message: %s", json); + } + } + + private void log(String message, Object...extras) { + trace("WebappPoller", message, extras); + } +} + +class GatewayRequest { + final List messages; + final List statusUpdates; + + GatewayRequest(List messages, List statusUpdates) { + this.messages = messages; + this.statusUpdates = statusUpdates; + } + + int wtMessageCount() { return messages.size(); } + int statusUpdateCount() { return statusUpdates.size(); } + + public JSONObject getJson() throws JSONException { + JSONObject json = new JSONObject(); + json.put("messages", getMessagesJson()); + json.put("updates", getStatusUpdateJson()); + return json; + } + + private JSONArray getMessagesJson() { + JSONArray json = new JSONArray(); + + for (WtMessage m : messages) { + try { + json.put(json( + "id", m.id, + "from", m.from, + "content", m.content, + "sms_sent", m.smsSent, + "sms_received", m.smsReceived + )); + m.setStatus(WtMessage.Status.FORWARDED); + } catch (Exception ex) { + logException(ex, "GatewayRequest.getMessagesJson()"); + m.setStatus(WtMessage.Status.FAILED); + } + } + + return json; + } + + private JSONArray getStatusUpdateJson() { + JSONArray json = new JSONArray(); + + for (WoMessage.StatusUpdate u : statusUpdates) { + try { + JSONObject deliveryUpdate = json( + "id", u.messageId, + "status", u.newStatus.toString() + ); + if (u.newStatus == WoMessage.Status.FAILED) { + deliveryUpdate.put("reason", u.failureReason); + } + json.put(deliveryUpdate); + } catch (Exception ex) { + logException(ex, "GatewayRequest.getStatusUpdateJson()"); + } + } + + return json; + } +} + +class LastPoll { + private static final String INTENT_UPDATED = "medic.gateway.WebappPoller.UPDATED"; + + private static final String PREF_LAST_TIMESTAMP = "last-timestamp"; + private static final String PREF_LAST_WAS_SUCCESSFUL = "last-was-successful"; + +//> INSTANCE + final long timestamp; + final boolean wasSuccessful; + LastPoll(long timestamp, boolean wasSuccessful) { + this.timestamp = timestamp; + this.wasSuccessful = wasSuccessful; + } + +//> PUBLIC UTILITIES + static void succeeded(Context ctx) { + logLast(ctx, true); + } + + static void failed(Context ctx) { + logLast(ctx, false); + } + + static LastPoll getFrom(Context ctx) { + SharedPreferences prefs = prefs(ctx); + + if (!prefs.contains(PREF_LAST_TIMESTAMP) || !prefs.contains(PREF_LAST_WAS_SUCCESSFUL)) { + return null; + } else { + return new LastPoll(prefs.getLong(PREF_LAST_TIMESTAMP, 0), + prefs.getBoolean(PREF_LAST_WAS_SUCCESSFUL, true)); + } + } + + static boolean isStatusUpdate(Intent i) { + return INTENT_UPDATED.equals(i.getAction()); + } + + public static void register(Context ctx, BroadcastReceiver receiver) { + IntentFilter f = new IntentFilter(); + f.addAction(INTENT_UPDATED); + LocalBroadcastManager.getInstance(ctx).registerReceiver(receiver, f); + } + + public static void unregister(Context ctx, BroadcastReceiver receiver) { + LocalBroadcastManager.getInstance(ctx).unregisterReceiver(receiver); + } + + public static void broadcast(Context ctx) { + Intent i = new Intent(INTENT_UPDATED); + LocalBroadcastManager.getInstance(ctx).sendBroadcast(i); + } + +//> STATIC HELPERS + private static SharedPreferences prefs(Context ctx) { + return ctx.getSharedPreferences(WebappPoller.class.getName(), Context.MODE_PRIVATE); + } + + private static void logLast(Context ctx, boolean success) { + SharedPreferences.Editor e = prefs(ctx).edit(); + e.putLong(PREF_LAST_TIMESTAMP, System.currentTimeMillis()); + e.putBoolean(PREF_LAST_WAS_SUCCESSFUL, success); + e.apply(); + } +} diff --git a/src/main/java/medic/gateway/alert/WebappUrlVerifier.java b/src/main/java/medic/gateway/alert/WebappUrlVerifier.java new file mode 100644 index 0000000..226c5c8 --- /dev/null +++ b/src/main/java/medic/gateway/alert/WebappUrlVerifier.java @@ -0,0 +1,88 @@ +package medic.gateway.alert; + +import android.content.Context; + +import java.net.MalformedURLException; +import javax.net.ssl.SSLException; + +import static medic.gateway.alert.BuildConfig.DISABLE_APP_URL_VALIDATION; +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.R.string.errInvalidUrl; +import static medic.gateway.alert.R.string.errWebappUrl_appNotFound; +import static medic.gateway.alert.R.string.errWebappUrl_badSsl; +import static medic.gateway.alert.R.string.errWebappUrl_serverNotFound; +import static medic.gateway.alert.R.string.errWebappUrl_unauthorised; +import static medic.gateway.alert.SimpleJsonClient2.redactUrl; +import static medic.gateway.alert.SimpleJsonClient2.uriEncodeAuth; + +public class WebappUrlVerifier { + private final Context ctx; + + WebappUrlVerifier(Context ctx) { + this.ctx = ctx; + } + + public WebappUrlVerififcation verify(String webappUrl) { + if(DISABLE_APP_URL_VALIDATION) { + return WebappUrlVerififcation.ok(webappUrl); + } + + try { + SimpleResponse response = new SimpleJsonClient2().get(uriEncodeAuth(webappUrl)); + + if(response instanceof JsonResponse && !response.isError()) + return handleJsonResponse(webappUrl, (JsonResponse) response); + else return handleFailResponse(webappUrl, response); + } catch(MalformedURLException ex) { + logException(ctx, ex, "Problem verifying url: %s", redactUrl(webappUrl)); + return WebappUrlVerififcation.failure(webappUrl, errInvalidUrl); + } + } + + private WebappUrlVerififcation handleJsonResponse(String webappUrl, JsonResponse response) { + if(response.json.optBoolean("pulsebridge-gateway")) + return WebappUrlVerififcation.ok(webappUrl); + + return WebappUrlVerififcation.failure(webappUrl, errWebappUrl_appNotFound); + } + + private WebappUrlVerififcation handleFailResponse(String webappUrl, SimpleResponse response) { + if(response instanceof ExceptionResponse) { + ExceptionResponse exR = (ExceptionResponse) response; + if(exR.ex instanceof SSLException) { + return WebappUrlVerififcation.failure(webappUrl, errWebappUrl_badSsl); + } + logException(ctx, exR.ex, "Exception caught while trying to validate server URL: %s", redactUrl(webappUrl)); + } + switch(response.status) { + case 401: + return WebappUrlVerififcation.failure(webappUrl, errWebappUrl_unauthorised); + case -1: + return WebappUrlVerififcation.failure(webappUrl, errWebappUrl_serverNotFound); + default: + return WebappUrlVerififcation.failure(webappUrl, errWebappUrl_appNotFound); + } + } +} + +@SuppressWarnings("PMD.ShortMethodName") +final class WebappUrlVerififcation { + public final String webappUrl; + public final boolean isOk; + public final int failure; + + private WebappUrlVerififcation(String webappUrl, boolean isOk, int failure) { + this.webappUrl = webappUrl; + this.isOk = isOk; + this.failure = failure; + } + +//> FACTORIES + public static WebappUrlVerififcation ok(String webappUrl) { + return new WebappUrlVerififcation(webappUrl, true, 0); + } + + public static WebappUrlVerififcation failure(String webappUrl, int failure) { + return new WebappUrlVerififcation(webappUrl, false, failure); + } +} diff --git a/src/main/java/medic/gateway/alert/WoListActivity.java b/src/main/java/medic/gateway/alert/WoListActivity.java new file mode 100644 index 0000000..784de6f --- /dev/null +++ b/src/main/java/medic/gateway/alert/WoListActivity.java @@ -0,0 +1,159 @@ +package medic.gateway.alert; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import androidx.fragment.app.FragmentActivity; +import android.view.View; +import android.widget.Button; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.LinkedList; +import java.util.Set; + +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.absoluteTimestamp; +import static medic.gateway.alert.Utils.showAlert; +import static medic.gateway.alert.Utils.NO_CLICK_LISTENER; +import static medic.gateway.alert.WoMessage.Status.UNSENT; +import static medic.gateway.alert.WoMessage.Status.FAILED; + +public class WoListActivity extends FragmentActivity { + private Db db; + private Set checkedMessageIds; + private Thinking thinking; + +//> LIFECYCLE + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.message_list_wo); + + this.db = Db.getInstance(this); + + ((Button) findViewById(R.id.btnRefreshWoMessageList)) + .setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { refreshList(); } + }); + + ((Button) findViewById(R.id.btnRetrySelected)) + .setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { retrySelected(); } + }); + + refreshList(); + } + + @Override public void onDestroy() { + if(thinking != null) thinking.dismiss(); + super.onDestroy(); + } + +//> API FOR WoListFragment + boolean isChecked(WoMessage m) { + return checkedMessageIds.contains(m.id); + } + + void updateChecked(WoMessage m, boolean isChecked) { + if(isChecked) checkedMessageIds.add(m.id); + else checkedMessageIds.remove(m.id); + + findViewById(R.id.btnRetrySelected).setEnabled(!checkedMessageIds.isEmpty()); + } + + void showMessageDetailDialog(final WoMessage m) { + thinking = Thinking.show(this); + AsyncTask.execute(new Runnable() { + public void run() { + try { + LinkedList content = new LinkedList<>(); + + content.add(string(R.string.lblTo, m.to)); + content.add(string(R.string.lblContent, m.content)); + content.add(string(R.string.lblStatusUpdates)); + + List updates = db.getStatusUpdates(m); + Collections.reverse(updates); + for(WoMessage.StatusUpdate u : updates) { + String status; + if(u.newStatus == FAILED) { + status = String.format("%s (%s)", u.newStatus, u.failureReason); + } else { + status = u.newStatus.toString(); + } + content.add(String.format("%s: %s", absoluteTimestamp(u.timestamp), status)); + } + + final AlertDialog.Builder dialog = new AlertDialog.Builder(WoListActivity.this); + if(m.status.canBeRetried()) { + dialog.setPositiveButton(R.string.btnRetry, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + retry(m.id); + resetScroll(); + refreshList(); + } + }); + } + + dialog.setItems(content.toArray(new String[content.size()]), NO_CLICK_LISTENER); + + showAlert(WoListActivity.this, dialog); + } catch(Exception ex) { + logException(WoListActivity.this, ex, "Failed to load WO message details."); + } finally { + thinking.dismiss(); + } + } + }); + } + +//> PRIVATE HELPERS + private void retry(String id) { + trace(this, "Retrying message with id %s...", id); + + WoMessage m = db.getWoMessage(id); + + if(!m.status.canBeRetried()) return; + + db.updateStatus(m, UNSENT); + } + + private final String string(int stringId, Object...args) { + return getString(stringId, args); + } + + private void resetScroll() { + // This implementation is far from ideal, but at least it works. + // Which is more than can be said for more logical options like: + // - getFragment().setSelection(0); + // - getFragment().getListView().setSelection(0); + // - getFragment().getListView().setSelectionAfterHeaderView(); + // ...and doing all of the above inside an AsyncTask. + getFragment().getListView().smoothScrollToPosition(0); + } + + private void refreshList() { + checkedMessageIds = new HashSet(); + + getSupportLoaderManager().restartLoader(WoListFragment.LOADER_ID, null, getFragment()); + + findViewById(R.id.btnRetrySelected).setEnabled(false); + } + + private void retrySelected() { + resetScroll(); + + for(String id : checkedMessageIds) retry(id); + + refreshList(); + } + + private WoListFragment getFragment() { + return (WoListFragment) getSupportFragmentManager() + .findFragmentById(R.id.lstWoMessages); + } +} diff --git a/src/main/java/medic/gateway/alert/WoListFragment.java b/src/main/java/medic/gateway/alert/WoListFragment.java new file mode 100644 index 0000000..212c6cc --- /dev/null +++ b/src/main/java/medic/gateway/alert/WoListFragment.java @@ -0,0 +1,117 @@ +package medic.gateway.alert; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.view.View; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.ListView; + +import androidx.fragment.app.ListFragment; +import androidx.loader.app.LoaderManager.LoaderCallbacks; +import androidx.loader.content.CursorLoader; +import androidx.loader.content.Loader; +import androidx.cursoradapter.widget.CursorAdapter; +import androidx.cursoradapter.widget.ResourceCursorAdapter; + +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.relativeTimestamp; +import static medic.gateway.alert.Utils.setText; +import static medic.gateway.alert.WoMessage.Status.FAILED; + +public class WoListFragment extends ListFragment implements LoaderCallbacks { + public static final int LOADER_ID = 3; + + @Override public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + WoCursorAdapter adapter = new WoCursorAdapter(getCastActivity()); + setListAdapter(adapter); + getLoaderManager().initLoader(LOADER_ID, null, this); + } + + public WoListActivity getCastActivity() { + return (WoListActivity) getActivity(); + } + +//> LoaderCallbacks + public Loader onCreateLoader(int id, Bundle args) { + setListShown(false); + return new WoCursorLoader(getActivity()); + } + + public void onLoadFinished(Loader loader, Cursor cursor) { + ((CursorAdapter) this.getListAdapter()).swapCursor(cursor); + setListShown(true); + } + + public void onLoaderReset(Loader loader) { + ((CursorAdapter) this.getListAdapter()).swapCursor(null); + } + +//> EVENT HANDLERS + @Override + public void onListItemClick(ListView list, View view, int position, long id) { + Cursor c = (Cursor) getListView().getItemAtPosition(position); + + // Get a fresh copy of the message, in case it's been updated + // more recently than the list + WoMessage m = Db.getInstance(getActivity()).getWoMessage(c.getString(0)); + + getCastActivity().showMessageDetailDialog(m); + } +} + +class WoCursorAdapter extends ResourceCursorAdapter { + private static final int NO_FLAGS = 0; + + private final WoListActivity activity; + + public WoCursorAdapter(WoListActivity activity) { + super(activity, R.layout.wo_list_item, null, NO_FLAGS); + this.activity = activity; + } + + public void bindView(View v, final Context ctx, Cursor c) { + final WoMessage m = Db.woMessageFrom(c); + + String status; + if(m.status == FAILED) { + status = String.format("%s (%s)", m.status, m.getFailureReason()); + } else { + status = m.status.toString(); + } + setText(v, R.id.txtWoStatus, status); + setText(v, R.id.txtWoLastAction, relativeTimestamp(m.lastAction)); + setText(v, R.id.txtWoTo, m.to); + setText(v, R.id.txtWoContent, m.content); + + CheckBox cbx = (CheckBox) v.findViewById(R.id.cbxMessage); + // Old list items get re-used, so we need to make sure that the + // checkbox is de-checked. + cbx.setChecked(activity.isChecked(m)); + cbx.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton btn, boolean isChecked) { + trace(this, "Changed checkbox to %s", isChecked); + activity.updateChecked(m, isChecked); + } + }); + } +} + +class WoCursorLoader extends CursorLoader { + private static final int MAX_WO_MESSAGES = 100; + + private final Db db; + + public WoCursorLoader(Context ctx) { + super(ctx); + this.db = Db.getInstance(ctx); + } + + public Cursor loadInBackground() { + return db.getWoMessages(MAX_WO_MESSAGES); + } +} diff --git a/src/main/java/medic/gateway/alert/WoMessage.java b/src/main/java/medic/gateway/alert/WoMessage.java new file mode 100644 index 0000000..43abe00 --- /dev/null +++ b/src/main/java/medic/gateway/alert/WoMessage.java @@ -0,0 +1,132 @@ +package medic.gateway.alert; + +import static medic.gateway.alert.GatewayLog.trace; + +/** + * WoMessage - Webapp-Originating Messages + * + * These are messages which originate at the webapp which cht-gateway is + * acting as SMS gateway for... or occasionally they may actually originate from + * cht-gateway itself, when cht-gateway is acting as the default messaging + * app on the device. + */ +class WoMessage { + public enum Status { + UNSENT, PENDING, SENT, FAILED, DELIVERED; + boolean canBeRetried() { return this != UNSENT && this != SENT && this != DELIVERED; } + } + + public static class StatusUpdate { + public final long id; + public final String messageId; + public final Status newStatus; + public final String failureReason; + public final long timestamp; + + public StatusUpdate(long id, String messageId, Status newStatus, String failureReason, long timestamp) { + this.id = id; + this.messageId = messageId; + this.newStatus = newStatus; + this.failureReason = failureReason; + this.timestamp = timestamp; + + if((newStatus == Status.FAILED) == (failureReason == null)) { + trace(this, "Attempting to set failure reason on a non-failed message: %s", this); + } + } + + public String toString() { + if(newStatus == Status.FAILED) { + return String.format("%s@%s-%s[%s]-%s", getClass().getSimpleName(), messageId, newStatus, failureReason, timestamp); + } else { + return String.format("%s@%s-%s-%s", getClass().getSimpleName(), messageId, newStatus, timestamp); + } + } + + public int hashCode() { + int p = 92821; + int h = 1; + h = h * p + (int) id; + return h; + } + + public boolean equals(Object _that) { + if(this == _that) return true; + if(!(_that instanceof StatusUpdate)) return false; + StatusUpdate that = (StatusUpdate) _that; + return this.id == that.id && + this.messageId == null ? that.messageId == null : this.messageId.equals(that.messageId) && + this.newStatus == that.newStatus && + this.failureReason == null ? that.failureReason == null : this.failureReason.equals(that.messageId) && + this.timestamp == that.timestamp; + } + } + + public final String id; + public final long lastAction; + public final Status status; + private final String failureReason; + public final String to; + public final String content; + // Retries is a counter. After a soft fail the WoMessage's status is set to UNSENT, then Gateway will retry to send it. + // After limit is reached, WoMessage will hard fail. It can be retried manually later. + public final int retries; + private static final int MAX_RETRIES_SOFT_FAIL = 20; // Aprox 12H when WAIT_RETRY_SOFT_FAIL is 1min + private static final int WAIT_RETRY_SOFT_FAIL = 60 * 1000; // Milliseconds + + public WoMessage(String id, String to, String content) { + this.id = id; + this.status = Status.UNSENT; + this.failureReason = null; + this.lastAction = System.currentTimeMillis(); + this.to = to; + this.content = content; + this.retries = 0; + } + + public WoMessage(String id, Status status, String failureReason, long lastAction, String to, String content, int retries) { + if ((status == Status.FAILED) == (failureReason == null)) { + throw new IllegalArgumentException(String.format( + "Provide a failureReason iff status is FAILED. (status=%s, reason=%s)", + status, + failureReason)); + } + + this.id = id; + this.status = status; + this.failureReason = failureReason; + this.lastAction = lastAction; + this.to = to; + this.content = content; + this.retries = retries; + } + +//> ACCESSORS + public String getFailureReason() { + if(status == Status.FAILED) return failureReason; + else throw new IllegalStateException("Cannot get failure reason unless status is FAILED"); + } + + public String toString() { + return String.format("%s@%s-%s", getClass().getSimpleName(), id, status); + } + + public boolean isMaxRetriesSoftFail() { + return this.retries >= MAX_RETRIES_SOFT_FAIL; + } + + /** + * The wait time is incremental according with the number of retries. + * @return time in milliseconds + */ + public int calcWaitTimeRetry(int retries) { + return (int) (WAIT_RETRY_SOFT_FAIL * Math.pow(retries, 1.5)); + } + + public boolean canRetryAfterSoftFail() { + long waitTime = this.lastAction + this.calcWaitTimeRetry(this.retries); + long currentTime = System.currentTimeMillis(); + + return currentTime >= waitTime; + } +} diff --git a/src/main/java/medic/gateway/alert/WtListActivity.java b/src/main/java/medic/gateway/alert/WtListActivity.java new file mode 100644 index 0000000..47fa905 --- /dev/null +++ b/src/main/java/medic/gateway/alert/WtListActivity.java @@ -0,0 +1,159 @@ +package medic.gateway.alert; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import androidx.fragment.app.FragmentActivity; + +import medic.gateway.alert.WtMessage.Status; + +import static medic.gateway.alert.GatewayLog.logException; +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.absoluteTimestamp; +import static medic.gateway.alert.Utils.showAlert; +import static medic.gateway.alert.Utils.NO_CLICK_LISTENER; +import static medic.gateway.alert.WtMessage.Status.WAITING; + +public class WtListActivity extends FragmentActivity { + private Db db; + private Set checkedMessageIds; + private Thinking thinking; + +//> LIFECYCLE + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.message_list_wt); + + this.db = Db.getInstance(this); + + ((Button) findViewById(R.id.btnRefreshWtMessageList)) + .setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { refreshList(); } + }); + + ((Button) findViewById(R.id.btnRetrySelected)) + .setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { retrySelected(); } + }); + + refreshList(); + } + + @Override public void onDestroy() { + if(thinking != null) thinking.dismiss(); + super.onDestroy(); + } + +//> API FOR WtListFragment + boolean isChecked(WtMessage m) { + return checkedMessageIds.contains(m.id); + } + + void updateChecked(WtMessage m, boolean isChecked) { + if(isChecked) checkedMessageIds.add(m.id); + else checkedMessageIds.remove(m.id); + + findViewById(R.id.btnRetrySelected).setEnabled(!checkedMessageIds.isEmpty()); + } + + void showMessageDetailDialog(final WtMessage m, final int position) { + thinking = Thinking.show(this); + AsyncTask.execute(new Runnable() { + public void run() { + try { + LinkedList content = new LinkedList<>(); + + content.add(string(R.string.lblFrom, m.from)); + content.add(string(R.string.lblContent, m.content)); + content.add(string(R.string.lblSent, absoluteTimestamp(m.smsSent))); + content.add(string(R.string.lblReceived, absoluteTimestamp(m.smsReceived))); + content.add(string(R.string.lblStatusUpdates)); + + List updates = db.getStatusUpdates(m); + Collections.reverse(updates); + for(WtMessage.StatusUpdate u : updates) { + content.add(String.format("%s: %s", absoluteTimestamp(u.timestamp), u.newStatus)); + } + + final AlertDialog.Builder dialog = new AlertDialog.Builder(WtListActivity.this); + if(m.getStatus().canBeRetried()) { + dialog.setPositiveButton(R.string.btnRetry, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + retry(m.id); + resetScroll(); + refreshList(); + } + }); + } + + dialog.setItems(content.toArray(new String[content.size()]), NO_CLICK_LISTENER); + + showAlert(WtListActivity.this, dialog); + } catch(Exception ex) { + logException(WtListActivity.this, ex, "Failed to load WT message details."); + } finally { + thinking.dismiss(); + } + } + }); + } + +//> PRIVATE HELPERS + private void retry(String id) { + trace(this, "Retrying message with id %s...", id); + + WtMessage m = db.getWtMessage(id); + + if(!m.getStatus().canBeRetried()) return; + + Status oldStatus = m.getStatus(); + m.setStatus(WAITING); + db.updateStatusFrom(oldStatus, m); + } + + private final String string(int stringId, Object...args) { + return getString(stringId, args); + } + + private void resetScroll() { + // This implementation is far from ideal, but at least it works. + // Which is more than can be said for more logical options like: + // - getFragment().setSelection(0); + // - getFragment().getListView().setSelection(0); + // - getFragment().getListView().setSelectionAfterHeaderView(); + // ...and doing all of the above inside an AsyncTask. + getFragment().getListView().smoothScrollToPosition(0); + } + + private void refreshList() { + checkedMessageIds = new HashSet(); + + getSupportLoaderManager().restartLoader(WtListFragment.LOADER_ID, null, getFragment()); + + findViewById(R.id.btnRetrySelected).setEnabled(false); + } + + private void retrySelected() { + resetScroll(); + + for(String id : checkedMessageIds) retry(id); + + refreshList(); + } + + private WtListFragment getFragment() { + return (WtListFragment) getSupportFragmentManager() + .findFragmentById(R.id.lstWtMessages); + } +} diff --git a/src/main/java/medic/gateway/alert/WtListFragment.java b/src/main/java/medic/gateway/alert/WtListFragment.java new file mode 100644 index 0000000..ccf0c3d --- /dev/null +++ b/src/main/java/medic/gateway/alert/WtListFragment.java @@ -0,0 +1,110 @@ +package medic.gateway.alert; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.view.View; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.ListView; + +import androidx.fragment.app.ListFragment; +import androidx.loader.app.LoaderManager.LoaderCallbacks; +import androidx.loader.content.CursorLoader; +import androidx.loader.content.Loader; +import androidx.cursoradapter.widget.CursorAdapter; +import androidx.cursoradapter.widget.ResourceCursorAdapter; + +import static medic.gateway.alert.GatewayLog.trace; +import static medic.gateway.alert.Utils.relativeTimestamp; +import static medic.gateway.alert.Utils.setText; + +public class WtListFragment extends ListFragment implements LoaderCallbacks { + public static final int LOADER_ID = 2; + + @Override public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + WtCursorAdapter adapter = new WtCursorAdapter(getCastActivity()); + setListAdapter(adapter); + getLoaderManager().initLoader(LOADER_ID, null, this); + } + + public WtListActivity getCastActivity() { + return (WtListActivity) getActivity(); + } + +//> LoaderCallbacks + public Loader onCreateLoader(int id, Bundle args) { + setListShown(false); + return new WtCursorLoader(getActivity()); + } + + public void onLoadFinished(Loader loader, Cursor cursor) { + ((CursorAdapter) this.getListAdapter()).swapCursor(cursor); + setListShown(true); + } + + public void onLoaderReset(Loader loader) { + ((CursorAdapter) this.getListAdapter()).swapCursor(null); + } + +//> EVENT HANDLERS + @Override + public void onListItemClick(ListView list, View view, int position, long id) { + Cursor c = (Cursor) list.getItemAtPosition(position); + + // Get a fresh copy of the message, in case it's been updated + // more recently than the list + WtMessage m = Db.getInstance(getActivity()).getWtMessage(c.getString(0)); + + getCastActivity().showMessageDetailDialog(m, position); + } +} + +class WtCursorAdapter extends ResourceCursorAdapter { + private static final int NO_FLAGS = 0; + + private final WtListActivity activity; + + public WtCursorAdapter(WtListActivity activity) { + super(activity, R.layout.wt_list_item, null, NO_FLAGS); + this.activity = activity; + } + + public void bindView(View v, final Context ctx, Cursor c) { + final WtMessage m = Db.wtMessageFrom(c); + + setText(v, R.id.txtWtStatus, m.getStatus().toString()); + setText(v, R.id.txtWtLastAction, relativeTimestamp(m.getLastAction())); + setText(v, R.id.txtWtFrom, m.from); + setText(v, R.id.txtWtContent, m.content); + + CheckBox cbx = (CheckBox) v.findViewById(R.id.cbxMessage); + // Old list items get re-used, so we need to make sure that the + // checkbox is de-checked. + cbx.setChecked(activity.isChecked(m)); + cbx.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton btn, boolean isChecked) { + trace(this, "Changed checkbox to %s", isChecked); + activity.updateChecked(m, isChecked); + } + }); + } +} + +class WtCursorLoader extends CursorLoader { + private static final int MAX_WT_MESSAGES = 100; + + private final Db db; + + public WtCursorLoader(Context ctx) { + super(ctx); + this.db = Db.getInstance(ctx); + } + + public Cursor loadInBackground() { + return db.getWtMessages(MAX_WT_MESSAGES); + } +} diff --git a/src/main/java/medic/gateway/alert/WtMessage.java b/src/main/java/medic/gateway/alert/WtMessage.java new file mode 100644 index 0000000..3f99d56 --- /dev/null +++ b/src/main/java/medic/gateway/alert/WtMessage.java @@ -0,0 +1,85 @@ +package medic.gateway.alert; + +import static medic.gateway.alert.Utils.randomUuid; + +/** + * WtMessage - Webapp-Terminating Messages + */ +class WtMessage { + public enum Status { + WAITING, FORWARDED, FAILED; + boolean canBeRetried() { return this == FAILED; } + } + + public static class StatusUpdate { + public final long id; + public final String messageId; + public final Status newStatus; + public final long timestamp; + public StatusUpdate(long id, String messageId, Status newStatus, long timestamp) { + this.id = id; + this.messageId = messageId; + this.newStatus = newStatus; + this.timestamp = timestamp; + } + public String toString() { + return String.format("%s@%s-%s-%s", getClass().getSimpleName(), messageId, newStatus, timestamp); + } + public int hashCode() { + int p = 92821; + int h = 1; + h = h * p + (int) id; + return h; + } + public boolean equals(Object _that) { + if(this == _that) return true; + if(!(_that instanceof StatusUpdate)) return false; + StatusUpdate that = (StatusUpdate) _that; + return this.id == that.id && + this.messageId == null ? that.messageId == null : this.messageId.equals(that.messageId) && + this.newStatus == that.newStatus && + this.timestamp == that.timestamp; + } + } + + public final String id; + private Status status; + private long lastAction; + public final String from; + public final String content; + public final long smsSent; + public final long smsReceived; + + // TODO smsSent in practice actually appears to be the time that the SMS was received at the gateway + public WtMessage(String from, String content, long smsSent) { + this.id = randomUuid(); + setStatus(Status.WAITING); + this.from = from; + this.content = content; + this.smsSent = smsSent; + this.smsReceived = lastAction; // TODO this appears to be a no-op + } + + public WtMessage(String id, Status status, long lastAction, String from, String content, long smsSent, long smsReceived) { + this.id = id; + this.status = status; + this.lastAction = lastAction; + this.from = from; + this.content = content; + this.smsSent = smsSent; + this.smsReceived = smsReceived; + } + +//> ACCESSORS + public Status getStatus() { return status; } + public void setStatus(Status status) { + this.lastAction = System.currentTimeMillis(); + this.status = status; + } + + public long getLastAction() { return lastAction; } + + public String toString() { + return String.format("%s@%s-%s", getClass().getSimpleName(), id, status); + } +} diff --git a/src/main/res/drawable/ic_launcher_foreground.xml b/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c090543 --- /dev/null +++ b/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/src/main/res/font/red_hat_display.xml b/src/main/res/font/red_hat_display.xml new file mode 100644 index 0000000..f4579ed --- /dev/null +++ b/src/main/res/font/red_hat_display.xml @@ -0,0 +1,7 @@ + + + diff --git a/src/main/res/layout/cbx_dummy_send_mode.xml b/src/main/res/layout/cbx_dummy_send_mode.xml new file mode 100644 index 0000000..64123d9 --- /dev/null +++ b/src/main/res/layout/cbx_dummy_send_mode.xml @@ -0,0 +1,8 @@ + + diff --git a/src/main/res/layout/composer.xml b/src/main/res/layout/composer.xml new file mode 100644 index 0000000..d260bd5 --- /dev/null +++ b/src/main/res/layout/composer.xml @@ -0,0 +1,46 @@ + + + + + + + + + +