Initial commit GH

This commit is contained in:
Amit Kumar Nandi 2024-03-07 01:32:21 +05:30
commit 3fbc678a30
143 changed files with 12711 additions and 0 deletions

77
.github/workflows/build.yml vendored Normal file
View file

@ -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

38
.github/workflows/publish.yml vendored Normal file
View file

@ -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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Project exclude paths
/.gradle/
/build/
/build/intermediates/javac/genericDebug/compileGenericDebugJavaWithJavac/classes/
/build/intermediates/javac/genericDebugUnitTest/compileGenericDebugUnitTestJavaWithJavac/classes/

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Android Vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="medic.gateway.alert.generic" />
</ConnectionSetting>
</option>
<option name="failureTypes">
<list>
<option value="FATAL" />
</list>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

6
.idea/compiler.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="pulsebridge-app">
<State />
</entry>
</value>
</component>
</project>

17
.idea/gradle.xml Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <http://www.gnu.org/licenses/>.
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
<http://www.gnu.org/licenses/>.

91
Makefile Normal file
View file

@ -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

315
README.md Normal file
View file

@ -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": <String: uuid, generated by `pulsebridge-gateway-app`>,
"from": <String: international phone number>,
"content": <String: message content>,
"sms_sent": <long: ms since unix epoch that message was sent>,
"sms_received": <long: ms since unix epoch that message was received>
},
...
],
"updates": [
{
"id": <String: uuid, generated by webapp>,
"status": <String: PENDING|SENT|DELIVERED|FAILED>,
"reason": <String: failure reason (optional - only present for status:FAILED)>
},
...
],
}
```
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": <String: uuid, generated by webapp>,
"to": <String: local or international phone number>,
"content": <String: message 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": <String: error 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 \<support@bitmutex.com>
## License
The software is provided under Apache 2.0 License. Contributions to this project are accepted under the same license.

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

221
build.gradle Normal file
View file

@ -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
}
}
}

211
config/checkstyle.xml Normal file
View file

@ -0,0 +1,211 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="fileExtensions" value="java, xml, properties"/>
<!-- Checks whether files end with a new line. -->
<!-- See http://checkstyle.sourceforge.net/config_misc.html#NewlineAtEndOfFile -->
<module name="NewlineAtEndOfFile">
<property name="severity" value="info"/>
</module>
<!-- Checks that property files contain the same keys. -->
<!-- See http://checkstyle.sourceforge.net/config_misc.html#Translation -->
<module name="Translation"/>
<!-- Checks for Size Violations. -->
<!-- See http://checkstyle.sourceforge.net/config_sizes.html -->
<module name="FileLength">
<property name="severity" value="ignore"/>
</module>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sourceforge.net/config_whitespace.html -->
<!-- Miscellaneous other checks. -->
<!-- See http://checkstyle.sourceforge.net/config_misc.html -->
<module name="RegexpSingleline">
<property name="format" value="\s+$"/>
<property name="minimum" value="0"/>
<property name="maximum" value="0"/>
<property name="message" value="Line has trailing spaces."/>
<property name="severity" value="info"/>
</module>
<module name="TreeWalker">
<!-- Allow suppression of checkstyle warnings via @SuppressWarnings annotation -->
<module name="SuppressWarningsHolder"/>
<!-- require TAB indentation -->
<module name="RegexpSinglelineJava">
<property name="format" value="^\t*(?! \*) "/>
<property name="message" value="Indent must use tab characters"/>
</module>
<!-- Checks for Javadoc comments. -->
<!-- See http://checkstyle.sourceforge.net/config_javadoc.html -->
<module name="JavadocMethod">
<property name="scope" value="package"/>
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingThrowsTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowThrowsTagsForSubclasses" value="true"/>
<property name="allowUndeclaredRTE" value="true"/>
<property name="allowMissingPropertyJavadoc" value="true"/>
<property name="severity" value="ignore"/>
</module>
<module name="JavadocType">
<property name="scope" value="package"/>
<property name="severity" value="ignore"/>
</module>
<module name="JavadocVariable">
<property name="scope" value="package"/>
<property name="severity" value="ignore"/>
</module>
<module name="JavadocStyle">
<property name="checkEmptyJavadoc" value="true"/>
<property name="severity" value="ignore"/>
</module>
<!-- Checks for Naming Conventions. -->
<!-- See http://checkstyle.sourceforge.net/config_naming.html -->
<module name="LocalFinalVariableName"/>
<module name="LocalVariableName">
<property name="format" value="^[a-z][a-z_A-Z0-9]*$"/>
</module>
<module name="MemberName"/>
<module name="MethodName">
<property name="severity" value="ignore"/>
</module>
<module name="PackageName"/>
<module name="ParameterName">
<property name="severity" value="ignore"/>
</module>
<module name="StaticVariableName">
<property name="severity" value="ignore"/>
</module>
<module name="TypeName"/>
<!-- Checks for imports -->
<!-- See http://checkstyle.sourceforge.net/config_import.html -->
<module name="IllegalImport"/>
<!-- defaults to sun.* packages -->
<module name="RedundantImport"/>
<module name="UnusedImports"/>
<!-- Checks for Size Violations. -->
<!-- See http://checkstyle.sourceforge.net/config_sizes.html -->
<module name="LineLength">
<property name="severity" value="ignore"/>
</module>
<module name="MethodLength"/>
<module name="ParameterNumber"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sourceforge.net/config_whitespace.html -->
<module name="EmptyForIteratorPad"/>
<module name="GenericWhitespace">
<property name="severity" value="ignore"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceAfter">
<property name="severity" value="ignore"/>
</module>
<module name="NoWhitespaceBefore">
<property name="severity" value="ignore"/>
</module>
<module name="OperatorWrap">
<property name="tokens" value="PLUS"/>
<property name="option" value="eol"/>
</module>
<module name="ParenPad"/>
<module name="TypecastParenPad"/>
<module name="WhitespaceAfter">
<property name="severity" value="ignore"/>
</module>
<module name="WhitespaceAround">
<property name="severity" value="ignore"/>
</module>
<!-- Modifier Checks -->
<!-- See http://checkstyle.sourceforge.net/config_modifiers.html -->
<module name="ModifierOrder">
<property name="severity" value="ignore"/>
</module>
<module name="RedundantModifier">
<property name="severity" value="ignore"/>
</module>
<!-- Checks for blocks. You know, those {}'s -->
<!-- See http://checkstyle.sourceforge.net/config_blocks.html -->
<module name="AvoidNestedBlocks"/>
<module name="EmptyBlock">
<property name="option" value="text"/>
</module>
<module name="LeftCurly">
<property name="severity" value="ignore"/>
</module>
<module name="NeedBraces">
<!-- cannot initialize module TreeWalker - Property 'allowSingleLineIf' in module NeedBraces does not exist, please check the documentation -->
<!-- u wot m8 -->
<!--<property name="allowSingleLineIf" value="TRUE"/> -->
<property name="severity" value="ignore"/>
</module>
<module name="RightCurly">
<property name="severity" value="ignore"/>
</module>
<!-- Checks for common coding problems -->
<!-- See http://checkstyle.sourceforge.net/config_coding.html -->
<module name="EmptyStatement"/>
<module name="EqualsHashCode"/>
<module name="HiddenField">
<property name="severity" value="ignore"/>
</module>
<module name="IllegalInstantiation"/>
<module name="InnerAssignment"/>
<module name="MagicNumber">
<property name="severity" value="ignore"/>
</module>
<module name="MissingSwitchDefault">
<property name="severity" value="ignore"/>
</module>
<module name="SimplifyBooleanExpression"/>
<module name="SimplifyBooleanReturn"/>
<!-- Checks for class design -->
<!-- See http://checkstyle.sourceforge.net/config_design.html -->
<module name="FinalClass">
<!-- this check is performed by PMD as well -->
<property name="severity" value="ignore"/>
</module>
<module name="HideUtilityClassConstructor"/>
<module name="InterfaceIsType">
<property name="severity" value="ignore"/>
</module>
<module name="VisibilityModifier">
<property name="severity" value="ignore"/>
</module>
<!-- Miscellaneous other checks. -->
<!-- See http://checkstyle.sourceforge.net/config_misc.html -->
<module name="ArrayTypeStyle"/>
<module name="TodoComment">
<property name="format" value="(?i)\s+CR\s+"/>
<property name="severity" value="info"/>
</module>
<module name="UpperEll"/>
</module>
<!-- Enable suppression comments -->
<!-- Allow suppression of checkstyle warnings via @SuppressWarnings annotation -->
<module name="SuppressWarningsFilter"/>
</module>

27
config/findbugs.xml Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<FindBugsFilter>
<Match>
<Class name="~.*\.BuildConfig"/>
</Match>
<Match>
<Class name="~.*\.R"/>
</Match>
<Match>
<Class name="~.*\.R\$.*"/>
</Match>
<Match>
<!-- As far as I can tell, our implementations of compareTo() in
these classes are safe. -->
<Class name="~^medic.gateway.alert.W[ot]Message$"/>
<Bug pattern="EQ_COMPARETO_USE_OBJECT_EQUALS"/>
</Match>
<Match>
<!-- At the time of writing, findbugs was wrong about these two -->
<Class name="medic.gateway.alert.SimpleJsonClient2"/>
<Bug pattern="NP_NULL_ON_SOME_PATH_EXCEPTION,DE_MIGHT_IGNORE"/>
</Match>
<Match>
<!-- On Android, the default encoding is always UTF-8 -->
<Bug pattern="DM_DEFAULT_ENCODING"/>
</Match>
</FindBugsFilter>

6
config/lint.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="UnusedAttribute">
<ignore regexp="autofillHints"/>
</issue>
</lint>

127
config/pmd.xml Normal file
View file

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
PMD Configuration
Severity: HARD
-->
<ruleset name="PMD ruleset for Checkstyle"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0
http://pmd.sourceforge.net/ruleset_2_0_0.xsd">
<description>POM rule set file</description>
<rule ref="rulesets/java/android.xml"/>
<rule ref="rulesets/java/basic.xml">
<exclude name="SimplifiedTernary" />
</rule>
<rule ref="rulesets/java/clone.xml"/>
<rule ref="rulesets/java/codesize.xml"/>
<rule ref="rulesets/java/codesize.xml/CyclomaticComplexity">
<properties>
<property name="reportLevel" value="20"/>
</properties>
</rule>
<rule ref="rulesets/java/codesize.xml/TooManyMethods">
<properties>
<property name="maxmethods" value="25"/>
</properties>
</rule>
<rule ref="rulesets/java/comments.xml">
<exclude name="CommentRequired"/>
<exclude name="CommentSize"/>
<exclude name="CommentDefaultAccessModifier" />
</rule>
<rule ref="rulesets/java/controversial.xml">
<exclude name="AssignmentInOperand"/>
<exclude name="AtLeastOneConstructor"/>
<exclude name="AvoidLiteralsInIfCondition"/>
<exclude name="AvoidPrefixingMethodParameters"/>
<exclude name="DataflowAnomalyAnalysis"/>
<exclude name="DefaultPackage"/>
<exclude name="NullAssignment"/>
<exclude name="OnlyOneReturn"/>
</rule>
<rule ref="rulesets/java/coupling.xml">
<exclude name="ExcessiveImports"/>
<exclude name="LawOfDemeter"/>
<exclude name="LoosePackageCoupling"/>
</rule>
<rule ref="rulesets/java/design.xml">
<exclude name="AvoidReassigningParameters"/>
<!-- if (x != y) { short code block } else { long code block } -->
<exclude name="ConfusingTernary"/>
<exclude name="SwitchStmtsShouldHaveDefault"/>
<!-- Android listeners contain a lot of such switch statements -->
<exclude name="TooFewBranchesForASwitchStatement"/>
<exclude name="ConstructorCallsOverridableMethod"/>
<exclude name="MissingBreakInSwitch"/>
<exclude name="FieldDeclarationsShouldBeAtStartOfClass"/>
<exclude name="AccessorMethodGeneration" />
</rule>
<rule ref="rulesets/java/empty.xml"/>
<rule ref="rulesets/java/finalizers.xml"/>
<rule ref="rulesets/java/imports.xml">
<exclude name="TooManyStaticImports"/>
</rule>
<rule ref="rulesets/java/junit.xml">
<exclude name="JUnitAssertionsShouldIncludeMessage"/>
<exclude name="JUnitTestContainsTooManyAsserts"/>
<exclude name="JUnitTestsShouldIncludeAssert" />
</rule>
<rule ref="rulesets/java/logging-jakarta-commons.xml">
<exclude name="GuardLogStatement"/>
</rule>
<rule ref="rulesets/java/logging-java.xml">
<exclude name="GuardLogStatementJavaUtil"/>
</rule>
<rule ref="rulesets/java/migrating.xml"/>
<rule ref="rulesets/java/naming.xml">
<exclude name="AbstractNaming"/>
<exclude name="AvoidFieldNameMatchingMethodName"/>
<exclude name="LongVariable"/>
<exclude name="MisleadingVariableName"/>
<exclude name="ShortClassName"/>
<exclude name="ShortVariable"/>
<exclude name="VariableNamingConventions"/>
<exclude name="MethodNamingConventions"/>
<exclude name="GenericsNaming"/>
</rule>
<rule ref="rulesets/java/optimizations.xml">
<exclude name="AvoidInstantiatingObjectsInLoops"/>
<exclude name="LocalVariableCouldBeFinal"/>
<exclude name="MethodArgumentCouldBeFinal"/>
</rule>
<rule ref="rulesets/java/strings.xml">
<exclude name="AvoidDuplicateLiterals"/>
</rule>
<rule ref="rulesets/java/typeresolution.xml">
<exclude name="LooseCoupling"/>
</rule>
<rule ref="rulesets/java/unnecessary.xml">
<exclude name="UselessQualifiedThis" />
</rule>
<rule ref="rulesets/java/unusedcode.xml"/>
</ruleset>

1
config/proguard.pro vendored Normal file
View file

@ -0,0 +1 @@
-keep public class medic.gateway.alert.AlarmListener

5
gradle.properties Normal file
View file

@ -0,0 +1,5 @@
android.enableJetifier=true
android.nonFinalResIds=false
android.nonTransitiveRClass=false
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -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

185
gradlew vendored Normal file
View file

@ -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" "$@"

89
gradlew.bat vendored Normal file
View file

@ -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

8
local.properties Normal file
View file

@ -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

BIN
pulsebridge.jks Normal file

Binary file not shown.

View file

@ -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 <num> | --discard=<num> 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<process.argv.length; ++i) {
arg = process.argv[i];
if(arg === '-d') {
discard = parseInt(process.argv[++i], 10);
} else if(~arg.indexOf('--discard=')) {
discard = parseInt(arg.substring(10));
}
}
function report(title, diffs) {
console.log('-----');
console.log('- ' + title);
console.log('Diffs: ' + diffs);
console.log('');
console.log('Min: ' + SS.min(diffs) + 's');
console.log('Max: ' + SS.max(diffs) + 's');
console.log('');
console.log('Mean: ' + SS.mean(diffs).toFixed(2) + 's');
console.log('Mode: ' + SS.mode(diffs) + 's');
console.log('Median: ' + SS.median(diffs) + 's');
console.log('');
console.log('Standard deviation: ' + SS.standardDeviation(diffs).toFixed(3) + 's');
console.log('-----');
}
const timestamps = [];
rl.on('line', (line) => {
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<timestamps.length; ++i) {
single_diffs[i-1] = (timestamps[i] - timestamps[i-1]) / 1000;
if(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.');
});

View file

@ -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

20
scripts/changelog Normal file
View file

@ -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/^/* /'

View file

@ -0,0 +1,49 @@
#!/bin/bash -eu
HTML='png_tester.html'
cd "$(dirname "$0")/../"
mkdir -p build/reports
cd $_
cat > $HTML << EOF
<html>
<head>
<title>PNG Transparency Tester</title>
<style>
body { font-size: .5em; }
#colour-picker div { width:2em; height:2em; display:inline-block; }
#pngs { background-color: green; }
#pngs div { border: 1px solid black; display:inline-block; }
</style>
</head>
<body>
<div id="colour-picker">
</div>
<div id="pngs">
EOF
for png in $(find '../../src' -name '*.png'); do
echo "<div><h3>${png:6}</h3><img src='$png'/></div>" >> $HTML
done
cat >> $HTML << EOF
</div>
<script>
['black', 'white', 'red', 'green', 'blue'].forEach(function(colour) {
var box = document.createElement('div');
box.onclick = function() { changeColour(colour); };
box.style.background = colour;
document.getElementById('colour-picker').append(box);
});
function changeColour(colour) {
console.log('Changing colour to: ' + colour);
document.getElementById('pngs').style.background = colour;
}
</script>
</body>
</html>
EOF
open "$HTML"

14
scripts/project_stats Normal file
View file

@ -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

1
settings.gradle Normal file
View file

@ -0,0 +1 @@
rootProject.name = 'pulsebridge-app'

View file

@ -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()));
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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<SettingsDialogActivity> 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());
}
}

View file

@ -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"));
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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)));
}
}

View file

@ -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<String> 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());
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- .alert is appended to the package name to fix Samsung AlarmListener bug as per
https://code.google.com/p/android/issues/detail?id=82001
https://stackoverflow.com/questions/34074955/android-exact-alarm-is-always-3-minutes-off/34085645 -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="android.support.test.espresso"/>
<!-- extra permissions used by instrumentation tests -->
<uses-permission android:name="android.permission.DISABLE_KEYGUARD"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
</manifest>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SMS Gateway</string>
</resources>

View file

@ -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<expectedRowCount; ++i) {
c.moveToNext();
String expectedRow = Arrays.toString(Arrays.copyOfRange(expectedValues, i * colCount, i * colCount + colCount));
for(int j=0; j<colCount; ++j) {
Object expected = expectedValues[i * colCount + j];
String actual = c.getString(j);
String failMessage = String.format("Unexpected value at row %s column %s. Expected row: %s.",
i, j, expectedRow);
if(expected == null) {
assertNull(failMessage, actual);
} else if(expected instanceof Pattern) {
assertMatches(failMessage, expected, actual);
} else if(expected instanceof Boolean) {
String expectedString = ((Boolean) expected) ? "1" : "0";
assertEquals(failMessage, expectedString, actual);
} else {
assertEquals(failMessage, expected.toString(), actual);
}
}
}
} finally {
c.close();
}
}
public static String[] args(String... args) {
return args;
}
public static String[] cols(String... columnNames) {
return columnNames;
}
public static Object[] vals(Object... vals) {
return vals;
}
public static String randomUuid() {
return randomUUID().toString();
}
public static long randomLong() {
return RANDOM.nextLong();
}
}

View file

@ -0,0 +1,92 @@
package medic.gateway.alert.test;
import android.content.*;
import android.database.*;
import android.database.sqlite.*;
import android.test.*;
import java.util.concurrent.*;
import medic.gateway.alert.*;
import okhttp3.mockwebserver.*;
import org.json.*;
import org.junit.*;
import static org.junit.Assert.*;
import static medic.gateway.alert.test.TestUtils.*;
@SuppressWarnings({"PMD.JUnit4TestShouldUseAfterAnnotation",
"PMD.SignatureDeclareThrowsException"})
public class HttpTestHelper {
public MockWebServer server;
public HttpTestHelper() throws Exception {
server = new MockWebServer();
server.start();
}
//> 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;
}
}
}

View file

@ -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 <T> void assertListEquals(List<T> 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);
}
}

View file

@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- .alert is appended to the package name to fix Samsung AlarmListener bug as per
https://code.google.com/p/android/issues/detail?id=82001
https://stackoverflow.com/questions/34074955/android-exact-alarm-is-always-3-minutes-off/34085645 -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<!-- communication permissions -->
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<!-- WakefulIntentService permissions -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- File permissions for log of unprocessed WT messages. READ is implied by WRITE. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- Required internally for sending multipart UCS2 messages - see:
https://github.com/medic/cht-gateway/issues/103 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:supportsRtl="false"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name="StartupActivity"
android:exported="true"
android:configChanges="orientation|screenSize"
android:theme="@android:style/Theme.NoDisplay"
>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="SettingsDialogActivity"/>
<activity android:name="PromptToSetAsDefaultMessageAppActivity"/>
<activity android:name="PromptForPermissionsActivity"/>
<activity android:name="ExternalLogProcessorActivity"/>
<activity android:name="MessageListsActivity"/>
<activity android:name="GatewayEventLogActivity"/>
<activity android:name="WoListActivity"/>
<activity android:name="WtListActivity"/>
<receiver android:name="IntentProcessor" android:label="@string/app_name"
android:permission="android.permission.BROADCAST_SMS"
android:exported="true">
<!--
priority is set here so that on supported android
versions we can abort SMS_RECEIVED broadcasts - we
don't want the incoming messages to reach the standard
inbox
-->
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_DELIVER"/>
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
</intent-filter>
</receiver>
<!-- Android 4.4+ (kitkat) SMS support -->
<receiver android:name="MmsIntentProcessor" android:label="@string/app_name"
android:permission="android.permission.BROADCAST_WAP_PUSH"
android:exported="true">
<intent-filter>
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER"/>
<data android:mimeType="application/vnd.wap.mms-message"/>
</intent-filter>
</receiver>
<receiver android:name="com.commonsware.cwac.wakeful.AlarmReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<!--
The following QUICKBOOT_POWERON intents are used on HTC phones when
a soft-restart is done from the OEM's power menu
-->
<action android:name="android.intent.action.QUICKBOOT_POWERON"/>
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
<meta-data android:name="com.commonsware.cwac.wakeful"
android:resource="@xml/wakeful"/>
</receiver>
<service android:name="WakefulService"/>
<!-- Android 4.4+ (kitkat) SMS support
I think this refers to an external Activity, although it may just be that it doesn't work currently.
Activity that allows the user to send new SMS/MMS messages -->
<activity android:name="ComposeSmsActivity"
android:windowSoftInputMode="adjustResize"
android:enabled="@bool/kitkat_plus"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SENDTO"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="sms"/>
<data android:scheme="smsto"/>
<data android:scheme="mms"/>
<data android:scheme="mmsto"/>
</intent-filter>
</activity>
<!-- Android 4.4+ (kitkat) SMS support
I think this refers to an external Service, although it may just be that it doesn't work currently.
Service that delivers messages from the phone "quick response" -->
<service android:name="HeadlessSmsSendService"
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
android:exported="true"
tools:ignore="MissingRegistered"
android:enabled="@bool/kitkat_plus">
<intent-filter>
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="sms"/>
<data android:scheme="smsto"/>
<data android:scheme="mms"/>
<data android:scheme="mmsto"/>
</intent-filter>
</service>
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -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<Parent extends Activity, Params, Progress, Result> extends AsyncTask<Params, Progress, Result> {
private final WeakReference<Parent> 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 <code>Throwable</code>s.
* @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 <code>null</code> 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;
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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));
}
}

View file

@ -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<WoMessage> 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<WoMessage> getWoMessages(int maxCount, WoMessage.Status status) {
return getWoMessages(eq(WOM_clmSTATUS), args(status), SortDirection.ASC, maxCount);
}
List<WoMessage.StatusUpdate> 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<WoMessage.StatusUpdate> 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<WoMessage> 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<WoMessage> 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<WoMessage.StatusUpdate> 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<WoMessage.StatusUpdate> 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<WtMessage> 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<WtMessage> getWtMessages(int maxCount, WtMessage.Status status) {
return getWtMessages(eq(WTM_clmSTATUS), args(status), SortDirection.ASC, maxCount);
}
private List<WtMessage> 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<WtMessage> 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<WtMessage.StatusUpdate> 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<WtMessage.StatusUpdate> 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<Object, Long> 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 + "<?";
}
private static String eq(String... cols) { // NOPMD
StringBuilder bob = new StringBuilder();
for(String col : cols) {
bob.append(" AND ")
.append(col)
.append("=?");
}
return bob.substring(5);
}
private String bool(boolean value) {
return value ? TRUE : FALSE;
}
private void log(String message, Object... extras) {
trace(this, message, extras);
}
@SuppressWarnings("PMD.UnusedPrivateMethod") // it's used - PMD bug?
private static int rawUpdateOrDelete(SQLiteDatabase db, String statement, String[] cols, String... args) {
statement = String.format(statement, (String[]) cols);
SQLiteStatement s = db.compileStatement(statement);
for(int i=args.length; i>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<Object, Long> statusCounts;
MessageReport(long womCount, long wtmCount, Map<Object, Long> 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;
}
}

View file

@ -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; i<wordCount; ++i) {
bob.append(' ');
bob.append(RANDOM_WORDS[r.nextInt(RANDOM_WORDS.length)]);
}
return bob.substring(1);
}
}

View file

@ -0,0 +1,130 @@
package medic.gateway.alert;
import android.content.Context;
import android.content.Intent;
import static java.lang.Integer.toHexString;
import static medic.gateway.alert.GatewayLog.logEvent;
import static medic.gateway.alert.SmsCompatibility.createFromPdu;
import static medic.gateway.alert.WoMessage.Status.DELIVERED;
/**
* TODO this will currently update the status of any multupart message
* to the status of the <i>last received part</i>. 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.");
}
}

View file

@ -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 <b>must not</b> 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);
}
}

View file

@ -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);
}
});
}
}

View file

@ -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);
}
}

View file

@ -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<Cursor> {
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<Cursor> onCreateLoader(int id, Bundle args) {
setListShown(false);
return new GatewayEventLogEntryCursorLoader(getActivity(), db);
}
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
((CursorAdapter) this.getListAdapter()).swapCursor(cursor);
setListShown(true);
}
public void onLoaderReset(Loader<Cursor> 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();
}
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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<Context, Void, Void> {
@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";
}
}
}

View file

@ -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<tabs.length; ++i) {
TabHost.TabSpec spec = tabHost.newTabSpec(tabs[i]);
spec.setIndicator(tabs[i]);
spec.setContent(new Intent(this, TAB_CLASSES[i]));
tabHost.addTab(spec);
}
updateForPollStatus();
LastPoll.register(this, pollUpdateReceiver);
}
@Override protected void onDestroy() {
LastPoll.unregister(this, pollUpdateReceiver);
if(thinking != null) thinking.dismiss();
super.onDestroy();
}
@Override public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.message_list_menu, menu);
return super.onCreateOptionsMenu(menu);
}
@SuppressLint("NonConstantResourceId")
@Override public boolean onOptionsItemSelected(MenuItem item) {
switch(item.getItemId()) {
case R.id.mnuMessageStats:
thinking = MessageStatsDialog.show(this);
return true;
case R.id.mnuCompose:
Intent composer;
if(getCapabilities().isDefaultSmsProvider(this)) {
composer = new Intent(this, ComposeSmsActivity.class);
} else {
composer = new Intent(Intent.ACTION_VIEW, Uri.parse("sms:"));
}
startActivity(composer);
return true;
case R.id.mnuDeleteOldData:
new AlertDialog.Builder(this)
.setTitle(R.string.txtConfirmDeleteOldData_title)
.setMessage(R.string.txtConfirmDeleteOldData_body)
.setPositiveButton(R.string.btnConfirmDeleteOldData_confirm, deleteOldDataHandler)
.setNegativeButton(R.string.btnCancel, cancelDialogHandler)
.show();
return true;
case R.id.mnuSettings:
openSettings();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
//> 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<MessageListsActivity, String, Void, Integer> {
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();
}
}
}

View file

@ -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<String> 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;
}
}

View file

@ -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());
}
}
}

View file

@ -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<PERMISSIONS_REQUESTS.length; ++p) {
if(!hasRequiredPermissions(a, p)) {
next = requestPermission(a, p);
break;
}
}
if(next == null) next = new Intent(a, ExternalLogProcessorActivity.class);
trace(a, "nextActivity() :: Should start activity with intent: %s", next);
a.startActivity(next);
a.finish();
}
private static Intent requestPermission(Activity a, int permissionsRequestType) {
trace(a, "requestPermission() :: p=%s", permissionsRequestType);
Intent i = new Intent(a, PromptForPermissionsActivity.class);
i.putExtra(X_PERMISSIONS_TYPE, permissionsRequestType);
i.putExtra(X_IS_DEMAND, false);
return i;
}
private static Intent demandPermissions(Activity a) {
trace(a, "demandPermission()");
Intent i = new Intent(a, PromptForPermissionsActivity.class);
i.putExtra(X_IS_DEMAND, true);
return i;
}
private static boolean canShowPromptFor(Activity a, int permissionsRequestType) {
trace(a, "canShowPromptFor() p=%s", permissionsRequestType);
for(String p : getPermissions(permissionsRequestType)) {
boolean shouldShow = ActivityCompat.shouldShowRequestPermissionRationale(a, p);
trace(a, "canShowPromptFor() can %s? %s", p, shouldShow);
if(!shouldShow) return false;
}
return true;
}
private static boolean hasRequiredPermissions(Activity a, int permissionsRequestType) {
trace(a, "hasRequiredPermissions() :: %s", permissionsRequestType);
for(String p : getPermissions(permissionsRequestType))
if(ContextCompat.checkSelfPermission(a, p) != PERMISSION_GRANTED)
return false;
return true;
}
private static String[] getPermissions(int permissionsRequestType) {
return (String[]) PERMISSIONS_REQUESTS[permissionsRequestType][1];
}
}

View file

@ -0,0 +1,110 @@
package medic.gateway.alert;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.role.RoleManager;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.provider.Telephony.Sms.Intents;
import android.view.View;
import static medic.gateway.alert.GatewayLog.trace;
import static medic.gateway.alert.Utils.getAppName;
import static medic.gateway.alert.Utils.setText;
public class PromptToSetAsDefaultMessageAppActivity extends Activity {
private static final int REQUEST_CHANGE_DEFAULT_MESSAGING_APP = 1;
private final Capabilities app;
public PromptToSetAsDefaultMessageAppActivity() {
super();
this.app = Capabilities.getCapabilities();
}
//> 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);
}
}

View file

@ -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<IllegalSetting> 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();
}
}

View file

@ -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<SettingsDialogActivity, Void, Void, WebappUrlVerififcation> {
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);
}
}
}

View file

@ -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<IllegalSetting> errors;
public IllegalSettingsException(List<IllegalSetting> errors) {
super(createMessage(errors));
this.errors = errors;
}
private static String createMessage(List<IllegalSetting> 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;
}
}

View file

@ -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;
/**
* <p>New and improved - SimpleJsonClient2 is SimpleJsonClient, but using <code>
* HttpURLConnection</code> instead of <code>DefaultHttpClient</code>.
* <p>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<args.length; i+=2) {
bob.append(';');
bob.append(args[i]);
bob.append('=');
bob.append(args[i+1]);
}
log(methodName, bob.length() > 0 ? bob.substring(1) : "");
}
private static void log(String methodName, String message, Object... extras) {
if(extras.length > 0) message = String.format(message, extras);
Log.d(LOG_TAG, "SimpleJsonClient2." + methodName + "() :: " + message);
}
private static void log(Exception ex, String message, Object... extras) {
if(extras.length > 0) message = String.format(message, extras);
Log.i(LOG_TAG, message, ex);
}
}
@SuppressWarnings("PMD.AbstractClassWithoutAbstractMethod")
abstract class SimpleResponse {
final int status;
SimpleResponse(int status) {
this.status = status;
}
boolean isError() {
return this.status < 200 || this.status >= 300;
}
}
class EmptyResponse extends SimpleResponse {
EmptyResponse(int status) { super(status); }
}
class TextResponse extends SimpleResponse {
final CharSequence text;
TextResponse(int status, CharSequence text) {
super(status);
this.text = text;
}
}
class ExceptionResponse extends SimpleResponse {
final Exception ex;
ExceptionResponse(int status, Exception ex) {
super(status);
this.ex = ex;
}
@Override boolean isError() { return true; }
public String toString() {
return new StringBuilder()
.append('[')
.append(status)
.append('|')
.append(ex)
.append(']')
.toString();
}
}
class JsonResponse extends SimpleResponse {
final JSONObject json;
JsonResponse(int status, CharSequence json) throws JSONException {
super(status);
this.json = new JSONObject(json.toString());
}
public String toString() {
return new StringBuilder()
.append('[')
.append(status)
.append('|')
.append(json)
.append(']')
.toString();
}
}

View file

@ -0,0 +1,55 @@
package medic.gateway.alert;
import android.os.Build;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.net.Uri;
import android.telephony.SmsMessage;
/**
* Because the android SMS API was not official before android-19, redefining
* these constants and methods here allows us to avoid various lint warnings/
* suppressions elsewhere in the code.
*/
final class SmsCompatibility {
@SuppressLint("NewApi") // Available in older APIs, but not officially public
public static final String SMS_RECEIVED_ACTION =
android.provider.Telephony.Sms.Intents.SMS_RECEIVED_ACTION;
/**
* Should only be used in android-19 and above; {@code TargetApi}
* annotation cannot be applied to a constant, otherwise it would be
* used here.
*/
@SuppressLint("NewApi")
public static final String SMS_DELIVER_ACTION =
android.provider.Telephony.Sms.Intents.SMS_DELIVER_ACTION;
@SuppressLint("NewApi") // Available in older APIs, but not officially public
public static final String WAP_PUSH_DELIVER_ACTION =
android.provider.Telephony.Sms.Intents.WAP_PUSH_DELIVER_ACTION;
@SuppressLint("NewApi") // Available in older APIs, but not officially public
public static final Uri SMS_INBOX = android.provider.Telephony.Sms.Inbox.CONTENT_URI;
private SmsCompatibility() {}
@SuppressLint("NewApi") // Available in older APIs, but not officially public
public static SmsMessage[] getMessagesFromIntent(Intent i) {
return android.provider.Telephony.Sms.Intents.getMessagesFromIntent(i);
}
/**
* @see https://developer.android.com/reference/android/telephony/SmsMessage.html#createFromPdu%28byte[],%20java.lang.String%29
*/
@SuppressLint("ObsoleteSdkInt") // lint seems to think checking for > 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);
}
}
}

View file

@ -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<WoMessage> 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<String> parts = divideMessageForCdma(m.content);
int totalParts = parts.size();
for(int partIndex=0; partIndex<totalParts; ++partIndex) {
String part = parts.get(partIndex);
smsManager.sendTextMessage(
m.to,
DEFAULT_SMSC,
part,
intentFor(SENDING_REPORT, m, partIndex, totalParts),
intentFor(DELIVERY_REPORT, m, partIndex, totalParts));
}
} else {
ArrayList<String> 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<PendingIntent> intentsFor(String intentType, WoMessage m, ArrayList<String> parts) {
int totalParts = parts.size();
ArrayList<PendingIntent> intents = new ArrayList<>(totalParts);
for(int partIndex=0; partIndex<totalParts; ++partIndex) {
intents.add(intentFor(intentType, m, partIndex, totalParts));
}
return intents;
}
@SuppressLint("UnspecifiedImmutableFlag")
private PendingIntent intentFor(String intentType, WoMessage m, int partIndex, int totalParts) {
Intent intent = new Intent(ctx, IntentProcessor.class);
intent.setAction(intentType);
intent.putExtra("id", m.id);
intent.putExtra("partIndex", partIndex);
intent.putExtra("totalParts", totalParts);
return PendingIntent.getBroadcast(ctx, m.id.hashCode(), intent, PendingIntent.FLAG_ONE_SHOT);
}
/**
* I haven't read the specs closely, but it's a good starting point to
* assume that CDMA SMS can be sent in ASCII mode (8 bits per character)
* or UTF-16 mode (16 bits per character).
*/
static final ArrayList<String> divideMessageForCdma(String content) {
ArrayList<String> 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<partCount; ++i) {
int startIndex = i*perMessageCharLimit;
int endIndex = Math.min(content.length(), startIndex + perMessageCharLimit);
parts.add(String.format("%s/%s %s", i+1, partCount,
content.substring(startIndex, endIndex)));
}
}
return parts;
}
/**
* Assumes CDMA 8-bit is ISO-8859-1. Java uses UTF-16 for char values,
* which is identical to ISO-8859-1 in the first 256 characters.
**/
private static boolean onlyExtendedAscii(String s) {
for(int i=s.length()-1; i>=0; --i)
if(s.charAt(i) > 255) return false;
return true;
}
private List<WoMessage> getUnsentMessages() {
List<WoMessage> unsentSms = db.getWoMessages(MAX_WO_MESSAGES, UNSENT);
List<WoMessage> smsForSending = new ArrayList<>();
for(WoMessage sms : unsentSms) {
if (sms.retries > 0) {
if (sms.canRetryAfterSoftFail()) {
smsForSending.add(sms);
}
} else {
smsForSending.add(sms);
}
}
return smsForSending;
}
}

View file

@ -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."); }
}
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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.");
}
}
}

View file

@ -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());
}
}
}

View file

@ -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<WtMessage> messages;
final List<WoMessage.StatusUpdate> statusUpdates;
GatewayRequest(List<WtMessage> messages, List<WoMessage.StatusUpdate> 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();
}
}

View file

@ -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);
}
}

View file

@ -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<String> 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<String> 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<WoMessage.StatusUpdate> 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<String>();
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);
}
}

View file

@ -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<Cursor> {
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<Cursor> onCreateLoader(int id, Bundle args) {
setListShown(false);
return new WoCursorLoader(getActivity());
}
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
((CursorAdapter) this.getListAdapter()).swapCursor(cursor);
setListShown(true);
}
public void onLoaderReset(Loader<Cursor> 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);
}
}

View file

@ -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;
}
}

View file

@ -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<String> 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<String> 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<WtMessage.StatusUpdate> 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<String>();
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);
}
}

View file

@ -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<Cursor> {
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<Cursor> onCreateLoader(int id, Bundle args) {
setListShown(false);
return new WtCursorLoader(getActivity());
}
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
((CursorAdapter) this.getListAdapter()).swapCursor(cursor);
setListShown(true);
}
public void onLoaderReset(Loader<Cursor> 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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="64"
android:viewportHeight="64">
<group android:scaleX="0.79588234"
android:scaleY="0.79588234"
android:translateX="6.5317645"
android:translateY="6.5317645">
<path
android:pathData="M32,32m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
android:fillColor="#3498db"/>
<path
android:pathData="M27,22L37,22A2,2 0,0 1,39 24L39,24A2,2 0,0 1,37 26L27,26A2,2 0,0 1,25 24L25,24A2,2 0,0 1,27 22z"
android:fillColor="#fff"/>
<path
android:pathData="M46.6,14.4a2,2 0,0 0,-2.8 0L32,26.2l-8.8,-8.8a2,2 0,0 0,-2.8 0,2 2,0 0,0 0,2.8l10.6,10.6a2,2 0,0 0,2.8 0L46.6,17a2,2 0,0 0,0 -2.8z"
android:fillColor="#fff"/>
</group>
</vector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto"
app:fontProviderAuthority="com.google.android.gms.fonts"
app:fontProviderPackage="com.google.android.gms"
app:fontProviderQuery="Red Hat Display"
app:fontProviderCerts="@array/com_google_android_gms_fonts_certs">
</font-family>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<CheckBox xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/cbxEnableDummySendMode"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/cbxEnableDummySendMode"
android:checked="false"/>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:orientation="vertical">
<EditText
android:id="@+id/txtComposer_recipients"
android:layout_width="fill_parent"
android:layout_height="71dp"
android:autofillHints=""
android:hint="@string/txtComposer_recipients"
android:inputType="textNoSuggestions"
android:lines="2" />
<RelativeLayout
android:layout_width="fill_parent"
android:layout_height="0dp"
android:layout_weight="1"
>
<EditText android:id="@+id/txtComposer_content"
android:inputType="text"
android:autofillHints=""
android:hint="@string/txtComposer_content"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_toLeftOf="@+id/btnComposer_send">
<requestFocus/>
</EditText>
<Button
android:id="@+id/btnComposer_send"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentBottom="true"
android:background="#3F51B5"
android:onClick="send"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:text="@string/btnComposer_send"
android:textColor="#FFFFFF" />
</RelativeLayout>
</LinearLayout>

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<fragment android:name="medic.gateway.alert.GatewayEventLogFragment"
android:id="@+id/lstGatewayEventLog"
android:layout_width="fill_parent"
android:layout_height="0dp"
android:layout_weight="1"
/>
<!-- TODO convert to toolbar -->
<LinearLayout
tools:ignore="ButtonStyle"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/btnRefreshLog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#4CAF50"
android:text="@string/btnRefresh"
android:textColor="#FFFFFF" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<TextView android:id="@+id/txtGatewayEventLogMessage"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="@dimen/lstMainText"
/>
<TextView android:id="@+id/txtGatewayEventLogDate"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="@dimen/lstSubText"
android:textColor="@android:color/tertiary_text_dark"
/>
</LinearLayout>

Some files were not shown because too many files have changed in this diff Show more