diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..791c54c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,53 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build and test
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ defaults:
+ run:
+ working-directory: android-app
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - uses: gradle/actions/setup-gradle@v5
+
+ - name: Unit tests
+ run: ./gradlew testFossDebugUnitTest testGmsDebugUnitTest
+
+ - name: Assemble debug APKs
+ run: ./gradlew assembleFossDebug assembleGmsDebug
+
+ - name: Upload test reports
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-reports
+ path: android-app/app/build/reports/tests/
+ retention-days: 7
+
+ - name: Upload debug APKs
+ uses: actions/upload-artifact@v4
+ with:
+ name: debug-apks
+ path: android-app/app/build/outputs/apk/*/debug/*.apk
+ retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ed20a94
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,87 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build and publish release
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ defaults:
+ run:
+ working-directory: android-app
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Check tag matches app version
+ id: version
+ run: |
+ tag="${GITHUB_REF_NAME#v}"
+ app_version=$(sed -n 's/.*ClientVersion\.validate("\([^"]*\)").*/\1/p' app/build.gradle.kts)
+ if [ "$tag" != "$app_version" ]; then
+ echo "::error::Tag $GITHUB_REF_NAME does not match versionName $app_version in app/build.gradle.kts"
+ exit 1
+ fi
+ echo "name=$app_version" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-read-only: true
+
+ - name: Restore signing keystore
+ env:
+ KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
+ run: |
+ if [ -z "$KEYSTORE_BASE64" ]; then
+ echo "::error::KEYSTORE_BASE64 secret is not set"
+ exit 1
+ fi
+ echo "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.jks"
+ echo "HHO_KEYSTORE_FILE=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV"
+
+ - name: Unit tests
+ run: ./gradlew testFossDebugUnitTest testGmsDebugUnitTest
+
+ - name: Assemble release APKs
+ env:
+ HHO_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ HHO_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ HHO_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ run: ./gradlew assembleFossRelease assembleGmsRelease
+
+ - name: Collect artifacts
+ env:
+ VERSION: ${{ steps.version.outputs.name }}
+ run: |
+ mkdir -p dist
+ for flavor in foss gms; do
+ cp "app/build/outputs/apk/$flavor/release/app-$flavor-release.apk" "dist/hho-$VERSION-$flavor.apk"
+ done
+ cd dist
+ sha256sum *.apk > SHA256SUMS
+
+ - name: Remove keystore
+ if: always()
+ run: rm -f "$RUNNER_TEMP/release.jks"
+
+ - name: Publish GitHub release
+ uses: softprops/action-gh-release@v2
+ with:
+ name: ${{ steps.version.outputs.name }}
+ generate_release_notes: true
+ fail_on_unmatched_files: true
+ files: |
+ android-app/dist/*.apk
+ android-app/dist/SHA256SUMS
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index 9a2ef3b..a8e65cf 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,27 @@
-# household-objects-android
-household-objects-android
+# HHO — Household Objects
+
+HHO is a **self-hosted home inventory and organization system**. You run it on your own
+hardware (a Raspberry Pi, a NAS, a home server), and it helps a household track what it owns:
+what's in the garage, which box has the holiday lights, whether the drill is still under
+warranty. No cloud account, no subscription, no external service required to operate it.
+
+HHO ships as two coupled products :
+
+- **HHO Server** ( `https://github.com/ankek/household-objects-srv/server/`) — a single Go binary / container. SQLite storage, an embedded
+ Vue 3 web UI, and a documented public REST API. This is the whole product for a browser-only
+ household.
+- **HHO Android app** (this one repository,`/android-app/`) — native Kotlin + Jetpack Compose, offline-first,
+ barcode-driven stock control for standing in front of a shelf with no signal. Syncs to the
+ server. *(Not yet functional — see [Project status](#project-status) below.)*
+
+Licensed **AGPL-3.0-only**; see [License](#license).
+
+## Project status
+This project is under development — the HHO Server MVP
+
+## License
+
+HHO and all components are licensed under the **GNU Affero General Public License v3.0 only** (`AGPL-3.0-only`) — see
+[`LICENSE`](LICENSE). The AGPL's network-use clause (section 13) is the deliberate reason it was
+chosen over plain GPL: if you run a modified version of HHO as a network service, you must offer
+its source to your users. Sorry, bro. Sad but true.
\ No newline at end of file
diff --git a/android-app/.gitignore b/android-app/.gitignore
new file mode 100644
index 0000000..4b91077
--- /dev/null
+++ b/android-app/.gitignore
@@ -0,0 +1,24 @@
+.gradle/
+build/
+app/build/
+captures/
+.cxx/
+
+local.properties
+.kotlin/
+
+*.apk
+*.aab
+*.ap_
+*.aar
+*.dex
+*.class
+*.jks
+*.keystore
+output.json
+
+.idea/
+*.iml
+.navigation/
+
+!gradle/wrapper/gradle-wrapper.jar
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..dc5863e
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -0,0 +1,500 @@
+import dev.hho.android.buildsrc.ClientVersion
+import org.openapitools.generator.gradle.plugin.tasks.GenerateTask
+import java.io.File
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.openapi.generator)
+}
+
+val hhoClientVersion = ClientVersion.validate("0.1.0")
+
+val releaseKeystore = providers.environmentVariable("HHO_KEYSTORE_FILE")
+ .orElse(providers.gradleProperty("hho.keystore.file"))
+ .orNull
+ ?.let(::file)
+ ?.takeIf { it.isFile }
+
+android {
+ namespace = "dev.hho.android"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "dev.hho.android"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = hhoClientVersion
+
+ buildConfigField("String", "HHO_CLIENT_VERSION", "\"$hhoClientVersion\"")
+
+ testInstrumentationRunner = "dev.hho.android.HhoHiltTestRunner"
+ }
+
+ flavorDimensions += "distribution"
+ productFlavors {
+ create("gms") {
+ dimension = "distribution"
+ }
+ create("foss") {
+ dimension = "distribution"
+ }
+ }
+
+ signingConfigs {
+ if (releaseKeystore != null) {
+ create("release") {
+ storeFile = releaseKeystore
+ storePassword = providers.environmentVariable("HHO_KEYSTORE_PASSWORD")
+ .orElse(providers.gradleProperty("hho.keystore.password")).orNull
+ keyAlias = providers.environmentVariable("HHO_KEY_ALIAS")
+ .orElse(providers.gradleProperty("hho.key.alias")).orNull
+ keyPassword = providers.environmentVariable("HHO_KEY_PASSWORD")
+ .orElse(providers.gradleProperty("hho.key.password")).orNull
+ }
+ }
+ }
+
+ buildTypes {
+ debug {
+ isMinifyEnabled = false
+ }
+ release {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ signingConfig = signingConfigs.findByName("release")
+ }
+ }
+
+ dependenciesInfo {
+ includeInApk = false
+ includeInBundle = false
+ }
+
+ compileOptions {
+ val java = JavaVersion.toVersion(libs.versions.jvmTarget.get())
+ sourceCompatibility = java
+ targetCompatibility = java
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ all {
+ it.jvmArgs(
+ "--add-opens=java.base/java.lang=ALL-UNNAMED",
+ "--add-opens=java.base/java.util=ALL-UNNAMED",
+ "--add-opens=java.base/java.io=ALL-UNNAMED",
+ "--add-opens=java.base/java.security=ALL-UNNAMED",
+ "--add-opens=java.base/java.net=ALL-UNNAMED",
+ "--add-opens=java.base/java.text=ALL-UNNAMED",
+ "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED",
+ "--add-opens=java.base/java.util.concurrent=ALL-UNNAMED",
+ "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED",
+ "--add-opens=java.base/sun.security.x509=ALL-UNNAMED",
+ "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED",
+ )
+ }
+ }
+ }
+
+ sourceSets {
+ getByName("main").kotlin.srcDir("src/main/kotlin")
+ getByName("test").kotlin.srcDir("src/test/kotlin")
+ getByName("debug").assets.srcDir("schemas")
+ getByName("androidTest").kotlin.srcDir("src/androidTest/kotlin")
+ getByName("gms").kotlin.srcDir("src/gms/kotlin")
+ getByName("foss").kotlin.srcDir("src/foss/kotlin")
+ getByName("testGms").kotlin.srcDir("src/testGms/kotlin")
+ getByName("testFoss").kotlin.srcDir("src/testFoss/kotlin")
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+
+ val composeBom = platform(libs.androidx.compose.bom)
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+
+ implementation(libs.hilt.android)
+ implementation(libs.androidx.hilt.navigation.compose)
+ ksp(libs.hilt.compiler)
+
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.okhttp)
+
+ implementation(libs.kotlinx.serialization.json)
+
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.view)
+ implementation(libs.androidx.appcompat)
+
+ "gmsImplementation"(libs.mlkit.barcode.scanning)
+
+ implementation(libs.zxing.core)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.okhttp.mockwebserver)
+ testImplementation(libs.kotlinx.coroutines.test)
+
+ kspTest(libs.dagger.compiler)
+
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.room.testing)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.test.ext.junit)
+
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
+
+ testImplementation(libs.androidx.work.testing)
+
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.test.rules)
+ androidTestImplementation(libs.androidx.test.espresso.core)
+ androidTestImplementation(libs.hilt.android.testing)
+ kspAndroidTest(libs.hilt.compiler)
+}
+
+val mergedDebugManifest =
+ layout.buildDirectory.file("intermediates/merged_manifest/fossDebug/processFossDebugMainManifest/AndroidManifest.xml")
+
+val checkNoWorkManagerDefaultInitializer = tasks.register("checkNoWorkManagerDefaultInitializer") {
+ group = "verification"
+ description = "T158e: fails if the merged debug manifest still registers WorkManager's " +
+ "default androidx.startup initializer (which would bypass HiltWorkerFactory)."
+
+ dependsOn("processFossDebugMainManifest")
+ val manifestProvider = mergedDebugManifest
+ inputs.file(manifestProvider)
+
+ doLast {
+ val manifest = manifestProvider.get().asFile
+ check(manifest.isFile) {
+ "checkNoWorkManagerDefaultInitializer: merged manifest not found at ${manifest.path} " +
+ "— AGP's output path changed; update this task rather than letting it pass vacuously."
+ }
+ val androidNs = "http://schemas.android.com/apk/res/android"
+ val doc = javax.xml.parsers.DocumentBuilderFactory.newInstance()
+ .apply { isNamespaceAware = true }
+ .newDocumentBuilder()
+ .parse(manifest)
+ val metaData = doc.getElementsByTagName("meta-data")
+ val registered = (0 until metaData.length)
+ .map { (metaData.item(it) as org.w3c.dom.Element).getAttributeNS(androidNs, "name") }
+ if ("androidx.work.WorkManagerInitializer" in registered) {
+ throw GradleException(
+ "Merged manifest (${manifest.path}) still contains androidx.work.WorkManagerInitializer: " +
+ "WorkManager would be initialised with its default WorkerFactory at process start, " +
+ "bypassing HhoApplication's HiltWorkerFactory, so SyncWorker could never be created. " +
+ "Restore the tools:node=\"remove\" meta-data entry in src/main/AndroidManifest.xml.",
+ )
+ }
+ }
+}
+
+tasks.configureEach {
+ if (name == "testGmsDebugUnitTest" || name == "testFossDebugUnitTest" || name == "check") {
+ dependsOn(checkNoWorkManagerDefaultInitializer)
+ }
+}
+
+ksp {
+ arg("room.schemaLocation", layout.projectDirectory.dir("schemas").asFile.path)
+ arg("room.generateKotlin", "true")
+}
+
+val apiClientPackagePath = "dev/hho/android/data/apiclient/generated"
+
+val openApiGenerateKotlinClient = tasks.register("openApiGenerateKotlinClient") {
+ group = "hho openapi"
+ description = "Generates the Kotlin API client from server/api/openapi.yaml into a scratch " +
+ "build directory (D3.3, T157a). Run `generateApiClient` instead to update the committed " +
+ "sources under data/apiclient/generated/."
+
+ generatorName.set("kotlin")
+ library.set("jvm-okhttp4")
+ inputSpec.set(rootProject.layout.projectDirectory.file("../server/api/openapi.yaml").asFile.absolutePath)
+ outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.path)
+ packageName.set("dev.hho.android.data.apiclient.generated")
+
+ configOptions.set(
+ mapOf(
+ "serializationLibrary" to "kotlinx_serialization",
+ "dateLibrary" to "java8",
+ "legacyDiscriminatorBehavior" to "false",
+ "enumPropertyNaming" to "PascalCase",
+ ),
+ )
+ globalProperties.set(
+ mapOf(
+ "apiTests" to "false",
+ "modelTests" to "false",
+ "apiDocs" to "false",
+ "modelDocs" to "false",
+ ),
+ )
+}
+
+val apiClientGeneratedDir = layout.projectDirectory.dir("src/main/kotlin/$apiClientPackagePath")
+val apiClientHeader = """
+ |// SPDX-License-Identifier: AGPL-3.0-only
+ |// Generated by OpenAPI Generator from server/api/openapi.yaml — DO NOT EDIT BY HAND.
+ |// Regenerate via `./gradlew :app:generateApiClient` (D3.3, T157a/T157b).
+
+""".trimMargin()
+
+tasks.register("generateApiClient") {
+ group = "hho openapi"
+ description = "Regenerates the committed Kotlin API client under " +
+ "data/apiclient/generated/ from server/api/openapi.yaml (D3.3, T157a/T157b)."
+
+ dependsOn(openApiGenerateKotlinClient)
+ from(layout.buildDirectory.dir("generated/openapi/src/main/kotlin/$apiClientPackagePath"))
+ into(apiClientGeneratedDir)
+
+ doLast {
+ apiClientGeneratedDir.asFileTree.matching { include("**/*.kt") }.forEach { file ->
+ val text = file.readText()
+ if (!text.startsWith(apiClientHeader)) {
+ file.writeText(apiClientHeader + text)
+ }
+ }
+ }
+}
+
+val apiClientDriftStageDir = layout.buildDirectory.dir("generated/openapi-drift-check")
+
+val stageApiClientForDriftCheck = tasks.register("stageApiClientForDriftCheck") {
+ group = "hho openapi"
+ description = "Regenerates the Kotlin API client into a scratch directory (never the " +
+ "committed src/ tree) for checkApiClientDrift to compare against (D3.3, T157d)."
+
+ dependsOn(openApiGenerateKotlinClient)
+ from(layout.buildDirectory.dir("generated/openapi/src/main/kotlin/$apiClientPackagePath"))
+ into(apiClientDriftStageDir)
+
+ doLast {
+ apiClientDriftStageDir.get().asFileTree.matching { include("**/*.kt") }.forEach { file ->
+ val text = file.readText()
+ if (!text.startsWith(apiClientHeader)) {
+ file.writeText(apiClientHeader + text)
+ }
+ }
+ }
+}
+
+tasks.register("checkApiClientDrift") {
+ group = "hho openapi"
+ description = "CI drift gate (D3.3, T157d, NFR-032): fails if the committed Kotlin API " +
+ "client under data/apiclient/generated/ differs, byte-for-byte, from what " +
+ "server/api/openapi.yaml generates today."
+
+ dependsOn(stageApiClientForDriftCheck)
+
+ doLast {
+ val committedDir = apiClientGeneratedDir.asFile
+ val stagedDir = apiClientDriftStageDir.get().asFile
+
+ fun relativeFiles(root: File) = root.walkTopDown().filter { it.isFile }
+ .map { it.relativeTo(root).path }.toSortedSet()
+
+ val committedFiles = relativeFiles(committedDir)
+ val stagedFiles = relativeFiles(stagedDir)
+ val staleInCommitted = (committedFiles - stagedFiles).toSortedSet()
+ val missingFromCommitted = (stagedFiles - committedFiles).toSortedSet()
+ val changed = (committedFiles intersect stagedFiles).filterTo(sortedSetOf()) { rel ->
+ !committedDir.resolve(rel).readBytes().contentEquals(stagedDir.resolve(rel).readBytes())
+ }
+
+ if (staleInCommitted.isEmpty() && missingFromCommitted.isEmpty() && changed.isEmpty()) {
+ logger.lifecycle(
+ "checkApiClientDrift: no drift — the committed API client matches " +
+ "server/api/openapi.yaml.",
+ )
+ return@doLast
+ }
+
+ val message = buildString {
+ appendLine(
+ "Generated Kotlin API client drift detected: data/apiclient/generated/ no " +
+ "longer matches what server/api/openapi.yaml generates.",
+ )
+ if (staleInCommitted.isNotEmpty()) {
+ appendLine(" stale (committed, no longer generated): $staleInCommitted")
+ }
+ if (missingFromCommitted.isNotEmpty()) {
+ appendLine(" missing (generated, not committed): $missingFromCommitted")
+ }
+ if (changed.isNotEmpty()) {
+ appendLine(" changed: $changed")
+ }
+ append("Fix: run './gradlew :app:generateApiClient' and commit the result.")
+ }
+ throw GradleException(message)
+ }
+}
+
+val apiClientAdditiveProbeDir = layout.buildDirectory.dir("generated/openapi-additive-probe")
+val apiClientAdditiveProbeSpec = apiClientAdditiveProbeDir.map { it.file("openapi.yaml") }
+val apiClientAdditiveProbeOutputDir = apiClientAdditiveProbeDir.map { it.dir("output") }
+
+val additiveProbeOperationId = "getDiagnosticsPing"
+val additiveProbePathYaml = """
+ | /diagnostics/ping:
+ | get:
+ | operationId: $additiveProbeOperationId
+ | tags: [diagnostics]
+ | summary: "T157e synthetic additive-drift-tolerance probe — never implemented server-side."
+ | responses:
+ | "200":
+ | description: OK
+ | content:
+ | application/json:
+ | schema:
+ | type: object
+ | properties:
+ | status:
+ | type: string
+ | required: [status]
+ |
+""".trimMargin()
+
+val stageAdditiveOpenApiProbeSpec = tasks.register("stageAdditiveOpenApiProbeSpec") {
+ group = "hho openapi"
+ description = "T157e: writes a scratch copy of server/api/openapi.yaml with one synthetic " +
+ "additive path grafted in, for checkApiClientAdditiveDriftTolerance to regenerate " +
+ "against. Never modifies the real openapi.yaml."
+
+ val realSpecFile = rootProject.layout.projectDirectory.file("../server/api/openapi.yaml").asFile
+ val outSpecProvider = apiClientAdditiveProbeSpec
+ inputs.file(realSpecFile)
+ outputs.file(outSpecProvider)
+
+ doLast {
+ val original = realSpecFile.readText()
+ check("/diagnostics/ping" !in original) {
+ "T157e's synthetic probe path '/diagnostics/ping' already exists in the real " +
+ "server/api/openapi.yaml — pick a different placeholder path for the probe, " +
+ "this check needs a path guaranteed not to be there yet."
+ }
+
+ val marker = "\npaths:\n"
+ val markerIndex = original.indexOf(marker)
+ check(markerIndex >= 0) {
+ "T157e: could not find a top-level 'paths:' section in server/api/openapi.yaml to " +
+ "graft the synthetic additive probe path onto."
+ }
+ val insertAt = markerIndex + marker.length
+ val mutated = original.substring(0, insertAt) + additiveProbePathYaml +
+ original.substring(insertAt)
+
+ val outFile = outSpecProvider.get().asFile
+ outFile.parentFile.mkdirs()
+ outFile.writeText(mutated)
+ }
+}
+
+val openApiGenerateAdditiveProbeClient = tasks.register("openApiGenerateAdditiveProbeClient") {
+ group = "hho openapi"
+ description = "T157e: regenerates the Kotlin API client from the mutated scratch spec " +
+ "(server/api/openapi.yaml plus one synthetic additive path) into its own scratch " +
+ "output directory."
+
+ dependsOn(stageAdditiveOpenApiProbeSpec)
+ generatorName.set("kotlin")
+ library.set("jvm-okhttp4")
+ inputSpec.set(apiClientAdditiveProbeSpec.get().asFile.absolutePath)
+ outputDir.set(apiClientAdditiveProbeOutputDir.get().asFile.path)
+ packageName.set("dev.hho.android.data.apiclient.generated")
+
+ configOptions.set(
+ mapOf(
+ "serializationLibrary" to "kotlinx_serialization",
+ "dateLibrary" to "java8",
+ "legacyDiscriminatorBehavior" to "false",
+ "enumPropertyNaming" to "PascalCase",
+ ),
+ )
+ globalProperties.set(
+ mapOf(
+ "apiTests" to "false",
+ "modelTests" to "false",
+ "apiDocs" to "false",
+ "modelDocs" to "false",
+ ),
+ )
+}
+
+tasks.register("checkApiClientAdditiveDriftTolerance") {
+ group = "hho openapi"
+ description = "T157e / NFR-032, D3.3 cross-phase check: proves an additive openapi.yaml " +
+ "path (a synthetic probe route standing in for a future real addition, e.g. D2.3's " +
+ "postponed /import/homebox/* per A161) regenerates cleanly instead of being treated " +
+ "as unexpected drift. Runs entirely against scratch copies of the spec and client " +
+ "output — never touches the real openapi.yaml or the committed generated/ client."
+
+ dependsOn(openApiGenerateAdditiveProbeClient)
+
+ doLast {
+ val outputDir = apiClientAdditiveProbeOutputDir.get().asFile
+ check(outputDir.exists() && outputDir.isDirectory) {
+ "checkApiClientAdditiveDriftTolerance: expected generator output directory at " +
+ "${outputDir.path} — the additive-probe generation run did not produce one."
+ }
+
+ val generatedFiles = outputDir.walkTopDown().filter { it.isFile }.toList()
+ check(generatedFiles.isNotEmpty()) {
+ "checkApiClientAdditiveDriftTolerance: additive-probe generation produced an empty " +
+ "output tree — regenerating against a spec with a new path must still emit files."
+ }
+
+ val newApiFile = generatedFiles.firstOrNull { it.name.equals("DiagnosticsApi.kt", ignoreCase = true) }
+ check(newApiFile != null) {
+ "checkApiClientAdditiveDriftTolerance: expected a generated DiagnosticsApi.kt for " +
+ "the synthetic /diagnostics/ping probe path (new tag => new file is how this " +
+ "check tells 'the new path was picked up' from 'generation silently ignored " +
+ "it'); got: ${generatedFiles.map { it.name }.sorted()}"
+ }
+ check(newApiFile.readText().contains(additiveProbeOperationId)) {
+ "checkApiClientAdditiveDriftTolerance: generated DiagnosticsApi.kt does not " +
+ "contain the probe operation '$additiveProbeOperationId'."
+ }
+
+ logger.lifecycle(
+ "checkApiClientAdditiveDriftTolerance: PASS — an additive openapi.yaml path " +
+ "regenerates cleanly (new file: ${newApiFile.relativeTo(outputDir)}), " +
+ "confirming the drift check above would pick it up rather than reject it.",
+ )
+ }
+}
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -0,0 +1 @@
+
diff --git a/android-app/app/schemas/dev.hho.android.data.room.HhoDatabase/1.json b/android-app/app/schemas/dev.hho.android.data.room.HhoDatabase/1.json
new file mode 100644
index 0000000..f7ba19f
--- /dev/null
+++ b/android-app/app/schemas/dev.hho.android.data.room.HhoDatabase/1.json
@@ -0,0 +1,876 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "be4445a849992c8135699d0c150a8c88",
+ "entities": [
+ {
+ "tableName": "item",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `location_id` TEXT, `quantity` INTEGER, `short_code` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "locationId",
+ "columnName": "location_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "quantity",
+ "columnName": "quantity",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_location_id",
+ "unique": false,
+ "columnNames": [
+ "location_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_location_id` ON `${TABLE_NAME}` (`location_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "warranty_block",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `is_lifetime` INTEGER NOT NULL, `holder` TEXT, `provider` TEXT, `starts_on` INTEGER, `expires_on` INTEGER, `notes` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isLifetime",
+ "columnName": "is_lifetime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "holder",
+ "columnName": "holder",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "provider",
+ "columnName": "provider",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "startsOn",
+ "columnName": "starts_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "expiresOn",
+ "columnName": "expires_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_warranty_block_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_warranty_block_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "sold_to_block",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `sale_price_minor` INTEGER NOT NULL, `buyer_name` TEXT, `sold_on` INTEGER, `notes` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "salePriceMinor",
+ "columnName": "sale_price_minor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "buyerName",
+ "columnName": "buyer_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "soldOn",
+ "columnName": "sold_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sold_to_block_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sold_to_block_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "purchased_from_block",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `purchase_price_minor` INTEGER NOT NULL, `vendor` TEXT, `purchased_on` INTEGER, `order_reference` TEXT, `notes` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "purchasePriceMinor",
+ "columnName": "purchase_price_minor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vendor",
+ "columnName": "vendor",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "purchasedOn",
+ "columnName": "purchased_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "orderReference",
+ "columnName": "order_reference",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_purchased_from_block_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_purchased_from_block_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "item_identification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `kind` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_identification_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_identification_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ },
+ {
+ "name": "index_item_identification_value",
+ "unique": false,
+ "columnNames": [
+ "value"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_identification_value` ON `${TABLE_NAME}` (`value`)"
+ }
+ ]
+ },
+ {
+ "tableName": "item_custom_field",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `name` TEXT NOT NULL, `field_type` TEXT NOT NULL, `field_def_id` TEXT, `text_value` TEXT, `number_value` TEXT, `bool_value` INTEGER, `date_value` INTEGER, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fieldType",
+ "columnName": "field_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fieldDefId",
+ "columnName": "field_def_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "textValue",
+ "columnName": "text_value",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numberValue",
+ "columnName": "number_value",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "boolValue",
+ "columnName": "bool_value",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateValue",
+ "columnName": "date_value",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_custom_field_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_custom_field_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "stock_adjustment",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `delta` INTEGER NOT NULL, `resulting_quantity` INTEGER NOT NULL, `reason` TEXT, `note` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "delta",
+ "columnName": "delta",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "resultingQuantity",
+ "columnName": "resulting_quantity",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reason",
+ "columnName": "reason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "note",
+ "columnName": "note",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_stock_adjustment_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_stock_adjustment_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "location",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `name` TEXT NOT NULL, `parent_id` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_location_parent_id",
+ "unique": false,
+ "columnNames": [
+ "parent_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_location_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "label",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "item_label",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT NOT NULL, `label_id` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "labelId",
+ "columnName": "label_id",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_label_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_label_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ },
+ {
+ "name": "index_item_label_label_id",
+ "unique": false,
+ "columnNames": [
+ "label_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_label_label_id` ON `${TABLE_NAME}` (`label_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "attachment",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT NOT NULL, `category` TEXT NOT NULL, `original_filename` TEXT NOT NULL, `content_type` TEXT NOT NULL, `size_bytes` INTEGER NOT NULL, `sha256` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "category",
+ "columnName": "category",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalFilename",
+ "columnName": "original_filename",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "size_bytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sha256",
+ "columnName": "sha256",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasThumbnail",
+ "columnName": "has_thumbnail",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_attachment_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_attachment_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `watermark` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "watermark",
+ "columnName": "watermark",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncedAt",
+ "columnName": "lastSyncedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be4445a849992c8135699d0c150a8c88')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/schemas/dev.hho.android.data.room.HhoDatabase/2.json b/android-app/app/schemas/dev.hho.android.data.room.HhoDatabase/2.json
new file mode 100644
index 0000000..2996566
--- /dev/null
+++ b/android-app/app/schemas/dev.hho.android.data.room.HhoDatabase/2.json
@@ -0,0 +1,1311 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "4745a84ecf5a5ab0344dd116f6b92f45",
+ "entities": [
+ {
+ "tableName": "item",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `location_id` TEXT, `quantity` INTEGER, `short_code` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "locationId",
+ "columnName": "location_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "quantity",
+ "columnName": "quantity",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_location_id",
+ "unique": false,
+ "columnNames": [
+ "location_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_location_id` ON `${TABLE_NAME}` (`location_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "warranty_block",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `is_lifetime` INTEGER NOT NULL, `holder` TEXT, `provider` TEXT, `starts_on` INTEGER, `expires_on` INTEGER, `notes` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isLifetime",
+ "columnName": "is_lifetime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "holder",
+ "columnName": "holder",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "provider",
+ "columnName": "provider",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "startsOn",
+ "columnName": "starts_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "expiresOn",
+ "columnName": "expires_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_warranty_block_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_warranty_block_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "sold_to_block",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `sale_price_minor` INTEGER NOT NULL, `buyer_name` TEXT, `sold_on` INTEGER, `notes` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "salePriceMinor",
+ "columnName": "sale_price_minor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "buyerName",
+ "columnName": "buyer_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "soldOn",
+ "columnName": "sold_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sold_to_block_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sold_to_block_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "purchased_from_block",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `purchase_price_minor` INTEGER NOT NULL, `vendor` TEXT, `purchased_on` INTEGER, `order_reference` TEXT, `notes` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "purchasePriceMinor",
+ "columnName": "purchase_price_minor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vendor",
+ "columnName": "vendor",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "purchasedOn",
+ "columnName": "purchased_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "orderReference",
+ "columnName": "order_reference",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_purchased_from_block_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_purchased_from_block_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "item_identification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `kind` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_identification_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_identification_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ },
+ {
+ "name": "index_item_identification_value",
+ "unique": false,
+ "columnNames": [
+ "value"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_identification_value` ON `${TABLE_NAME}` (`value`)"
+ }
+ ]
+ },
+ {
+ "tableName": "item_custom_field",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `name` TEXT NOT NULL, `field_type` TEXT NOT NULL, `field_def_id` TEXT, `text_value` TEXT, `number_value` TEXT, `bool_value` INTEGER, `date_value` INTEGER, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fieldType",
+ "columnName": "field_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fieldDefId",
+ "columnName": "field_def_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "textValue",
+ "columnName": "text_value",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numberValue",
+ "columnName": "number_value",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "boolValue",
+ "columnName": "bool_value",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateValue",
+ "columnName": "date_value",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_custom_field_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_custom_field_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "stock_adjustment",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT, `delta` INTEGER NOT NULL, `resulting_quantity` INTEGER NOT NULL, `reason` TEXT, `note` TEXT, `created_at` INTEGER, `updated_at` INTEGER, `version` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "delta",
+ "columnName": "delta",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "resultingQuantity",
+ "columnName": "resulting_quantity",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reason",
+ "columnName": "reason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "note",
+ "columnName": "note",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_stock_adjustment_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_stock_adjustment_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "location",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `name` TEXT NOT NULL, `parent_id` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_location_parent_id",
+ "unique": false,
+ "columnNames": [
+ "parent_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_location_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "label",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "item_label",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT NOT NULL, `label_id` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "labelId",
+ "columnName": "label_id",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_item_label_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_label_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ },
+ {
+ "name": "index_item_label_label_id",
+ "unique": false,
+ "columnNames": [
+ "label_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_item_label_label_id` ON `${TABLE_NAME}` (`label_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "attachment",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `group_change_seq` INTEGER NOT NULL, `item_id` TEXT NOT NULL, `category` TEXT NOT NULL, `original_filename` TEXT NOT NULL, `content_type` TEXT NOT NULL, `size_bytes` INTEGER NOT NULL, `sha256` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupChangeSeq",
+ "columnName": "group_change_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "category",
+ "columnName": "category",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalFilename",
+ "columnName": "original_filename",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "size_bytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sha256",
+ "columnName": "sha256",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasThumbnail",
+ "columnName": "has_thumbnail",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_attachment_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_attachment_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `watermark` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "watermark",
+ "columnName": "watermark",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncedAt",
+ "columnName": "lastSyncedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "outbox_mutation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`seq` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mutation_id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `op` TEXT NOT NULL, `base_version` INTEGER NOT NULL, `fields_json` TEXT NOT NULL, `state` TEXT NOT NULL, `attempt_count` INTEGER NOT NULL, `last_error` TEXT, `last_attempt_at` INTEGER, `created_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "seq",
+ "columnName": "seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutationId",
+ "columnName": "mutation_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityType",
+ "columnName": "entity_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityId",
+ "columnName": "entity_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "op",
+ "columnName": "op",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseVersion",
+ "columnName": "base_version",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fieldsJson",
+ "columnName": "fields_json",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attemptCount",
+ "columnName": "attempt_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "last_error",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastAttemptAt",
+ "columnName": "last_attempt_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "seq"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_outbox_mutation_mutation_id",
+ "unique": true,
+ "columnNames": [
+ "mutation_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_outbox_mutation_mutation_id` ON `${TABLE_NAME}` (`mutation_id`)"
+ },
+ {
+ "name": "index_outbox_mutation_state_seq",
+ "unique": false,
+ "columnNames": [
+ "state",
+ "seq"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_mutation_state_seq` ON `${TABLE_NAME}` (`state`, `seq`)"
+ },
+ {
+ "name": "index_outbox_mutation_entity_type_entity_id",
+ "unique": false,
+ "columnNames": [
+ "entity_type",
+ "entity_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_mutation_entity_type_entity_id` ON `${TABLE_NAME}` (`entity_type`, `entity_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "receiving_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `vendor` TEXT NOT NULL, `order_reference` TEXT, `purchased_on` INTEGER, `status` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `completed_at` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vendor",
+ "columnName": "vendor",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "orderReference",
+ "columnName": "order_reference",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "purchasedOn",
+ "columnName": "purchased_on",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "completedAt",
+ "columnName": "completed_at",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_receiving_session_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_receiving_session_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ]
+ },
+ {
+ "tableName": "receiving_line",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `expected_qty` INTEGER, `received_qty` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `item_id`))",
+ "fields": [
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expectedQty",
+ "columnName": "expected_qty",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "receivedQty",
+ "columnName": "received_qty",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "session_id",
+ "item_id"
+ ]
+ }
+ },
+ {
+ "tableName": "photo_queue_entry",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `file_path` TEXT NOT NULL, `sha256` TEXT NOT NULL, `size_bytes` INTEGER NOT NULL, `category` TEXT NOT NULL, `state` TEXT NOT NULL, `attempt_count` INTEGER NOT NULL, `next_attempt_at` INTEGER, `last_error` TEXT, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sha256",
+ "columnName": "sha256",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "size_bytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "category",
+ "columnName": "category",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attemptCount",
+ "columnName": "attempt_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nextAttemptAt",
+ "columnName": "next_attempt_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "last_error",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_photo_queue_entry_state_created_at",
+ "unique": false,
+ "columnNames": [
+ "state",
+ "created_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_photo_queue_entry_state_created_at` ON `${TABLE_NAME}` (`state`, `created_at`)"
+ },
+ {
+ "name": "index_photo_queue_entry_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_photo_queue_entry_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "conflict_record",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mutation_id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `field_name` TEXT NOT NULL, `losing_value_json` TEXT, `server_value_json` TEXT, `detected_at` INTEGER NOT NULL, `origin` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutationId",
+ "columnName": "mutation_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityType",
+ "columnName": "entity_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityId",
+ "columnName": "entity_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fieldName",
+ "columnName": "field_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "losingValueJson",
+ "columnName": "losing_value_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverValueJson",
+ "columnName": "server_value_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "detectedAt",
+ "columnName": "detected_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "origin",
+ "columnName": "origin",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_conflict_record_mutation_id_entity_id_field_name",
+ "unique": true,
+ "columnNames": [
+ "mutation_id",
+ "entity_id",
+ "field_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conflict_record_mutation_id_entity_id_field_name` ON `${TABLE_NAME}` (`mutation_id`, `entity_id`, `field_name`)"
+ }
+ ]
+ },
+ {
+ "tableName": "sync_run_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `last_run_at` INTEGER, `last_push_at` INTEGER, `last_error` TEXT, `last_error_at` INTEGER, `conflict_cursor` TEXT, `reconcile_pending` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastRunAt",
+ "columnName": "last_run_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastPushAt",
+ "columnName": "last_push_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "last_error",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastErrorAt",
+ "columnName": "last_error_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "conflictCursor",
+ "columnName": "conflict_cursor",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "reconcilePending",
+ "columnName": "reconcile_pending",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4745a84ecf5a5ab0344dd116f6b92f45')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/androidTest/kotlin/dev/hho/android/HhoHiltTestRunner.kt b/android-app/app/src/androidTest/kotlin/dev/hho/android/HhoHiltTestRunner.kt
new file mode 100644
index 0000000..d09639c
--- /dev/null
+++ b/android-app/app/src/androidTest/kotlin/dev/hho/android/HhoHiltTestRunner.kt
@@ -0,0 +1,11 @@
+package dev.hho.android
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+import dagger.hilt.android.testing.HiltTestApplication
+
+class HhoHiltTestRunner : AndroidJUnitRunner() {
+ override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application =
+ super.newApplication(cl, HiltTestApplication::class.java.name, context)
+}
diff --git a/android-app/app/src/androidTest/kotlin/dev/hho/android/testing/AirplaneModeRule.kt b/android-app/app/src/androidTest/kotlin/dev/hho/android/testing/AirplaneModeRule.kt
new file mode 100644
index 0000000..885bc69
--- /dev/null
+++ b/android-app/app/src/androidTest/kotlin/dev/hho/android/testing/AirplaneModeRule.kt
@@ -0,0 +1,50 @@
+package dev.hho.android.testing
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.SystemClock
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.rules.ExternalResource
+import java.io.FileInputStream
+
+class AirplaneModeRule : ExternalResource() {
+
+ private val instrumentation get() = InstrumentationRegistry.getInstrumentation()
+
+ override fun before() {
+ shell("cmd connectivity airplane-mode enable")
+ shell("svc wifi disable")
+ shell("svc data disable")
+ val deadline = SystemClock.elapsedRealtime() + TIMEOUT_MILLIS
+ while (hasInternet()) {
+ check(SystemClock.elapsedRealtime() < deadline) {
+ "Device still reports an active internet-capable network ${TIMEOUT_MILLIS}ms after enabling airplane mode"
+ }
+ Thread.sleep(POLL_MILLIS)
+ }
+ }
+
+ override fun after() {
+ shell("cmd connectivity airplane-mode disable")
+ shell("svc wifi enable")
+ shell("svc data enable")
+ }
+
+ fun hasInternet(): Boolean {
+ val cm = instrumentation.targetContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
+ return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ private fun shell(command: String) {
+ instrumentation.uiAutomation.executeShellCommand(command).use { pfd ->
+ FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
+ }
+ }
+
+ private companion object {
+ const val TIMEOUT_MILLIS = 20_000L
+ const val POLL_MILLIS = 250L
+ }
+}
diff --git a/android-app/app/src/androidTest/kotlin/dev/hho/android/testing/TestModules.kt b/android-app/app/src/androidTest/kotlin/dev/hho/android/testing/TestModules.kt
new file mode 100644
index 0000000..9a60729
--- /dev/null
+++ b/android-app/app/src/androidTest/kotlin/dev/hho/android/testing/TestModules.kt
@@ -0,0 +1,96 @@
+package dev.hho.android.testing
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.testing.TestInstallIn
+import dev.hho.android.data.room.HhoDatabase
+import dev.hho.android.di.DataStoreModule
+import dev.hho.android.di.DeviceTokenDataStore
+import dev.hho.android.di.NetworkModule
+import dev.hho.android.di.ProbeHttpClient
+import dev.hho.android.di.RoomModule
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import java.io.File
+import java.io.IOException
+import java.util.UUID
+import java.util.Collections
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NetworkTripwire @Inject constructor() : Interceptor {
+ val requests: MutableList = Collections.synchronizedList(mutableListOf())
+
+ override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
+ requests += "${chain.request().method} ${chain.request().url}"
+ throw IOException("NetworkTripwire: network access attempted in an offline test")
+ }
+}
+
+@Module
+@TestInstallIn(components = [SingletonComponent::class], replaces = [RoomModule::class])
+object TestRoomModule {
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): HhoDatabase =
+ Room.inMemoryDatabaseBuilder(context, HhoDatabase::class.java).allowMainThreadQueries().build()
+
+ @Provides fun itemDao(db: HhoDatabase) = db.itemDao()
+ @Provides fun warrantyBlockDao(db: HhoDatabase) = db.warrantyBlockDao()
+ @Provides fun soldToBlockDao(db: HhoDatabase) = db.soldToBlockDao()
+ @Provides fun purchasedFromBlockDao(db: HhoDatabase) = db.purchasedFromBlockDao()
+ @Provides fun itemIdentificationDao(db: HhoDatabase) = db.itemIdentificationDao()
+ @Provides fun itemCustomFieldDao(db: HhoDatabase) = db.itemCustomFieldDao()
+ @Provides fun stockAdjustmentDao(db: HhoDatabase) = db.stockAdjustmentDao()
+ @Provides fun locationDao(db: HhoDatabase) = db.locationDao()
+ @Provides fun labelDao(db: HhoDatabase) = db.labelDao()
+ @Provides fun itemLabelDao(db: HhoDatabase) = db.itemLabelDao()
+ @Provides fun attachmentDao(db: HhoDatabase) = db.attachmentDao()
+ @Provides fun syncStateDao(db: HhoDatabase) = db.syncStateDao()
+ @Provides fun outboxDao(db: HhoDatabase) = db.outboxDao()
+ @Provides fun receivingDao(db: HhoDatabase) = db.receivingDao()
+ @Provides fun photoQueueDao(db: HhoDatabase) = db.photoQueueDao()
+ @Provides fun conflictRecordDao(db: HhoDatabase) = db.conflictRecordDao()
+ @Provides fun syncRunStateDao(db: HhoDatabase) = db.syncRunStateDao()
+}
+
+@Module
+@TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkModule::class])
+object TestNetworkModule {
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(tripwire: NetworkTripwire): OkHttpClient =
+ OkHttpClient.Builder().addInterceptor(tripwire).build()
+
+ @Provides
+ @Singleton
+ @ProbeHttpClient
+ fun provideProbeOkHttpClient(tripwire: NetworkTripwire): OkHttpClient =
+ OkHttpClient.Builder().addInterceptor(tripwire).build()
+}
+
+@Module
+@TestInstallIn(components = [SingletonComponent::class], replaces = [DataStoreModule::class])
+object TestDataStoreModule {
+ @Provides
+ @Singleton
+ fun provideSettingsDataStore(@ApplicationContext context: Context): DataStore = fresh(context)
+
+ @Provides
+ @Singleton
+ @DeviceTokenDataStore
+ fun provideDeviceTokenDataStore(@ApplicationContext context: Context): DataStore = fresh(context)
+
+ private fun fresh(context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ produceFile = { File(context.cacheDir, "test-${UUID.randomUUID()}.preferences_pb") },
+ )
+}
diff --git a/android-app/app/src/androidTest/kotlin/dev/hho/android/ui/items/OfflineEditsInstrumentedTest.kt b/android-app/app/src/androidTest/kotlin/dev/hho/android/ui/items/OfflineEditsInstrumentedTest.kt
new file mode 100644
index 0000000..418f270
--- /dev/null
+++ b/android-app/app/src/androidTest/kotlin/dev/hho/android/ui/items/OfflineEditsInstrumentedTest.kt
@@ -0,0 +1,160 @@
+package dev.hho.android.ui.items
+
+import android.view.ViewGroup
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasSetTextAction
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.performTextReplacement
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dev.hho.android.MainActivity
+import dev.hho.android.data.room.HhoDatabase
+import dev.hho.android.data.room.ItemEntity
+import dev.hho.android.testing.AirplaneModeRule
+import dev.hho.android.testing.NetworkTripwire
+import dev.hho.android.ui.theme.HhoTheme
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import javax.inject.Inject
+
+@HiltAndroidTest
+@RunWith(AndroidJUnit4::class)
+class OfflineEditsInstrumentedTest {
+
+ private val hiltRule = HiltAndroidRule(this)
+ private val airplane = AirplaneModeRule()
+ private val compose = createAndroidComposeRule()
+
+ @get:Rule
+ val chain: RuleChain = RuleChain.outerRule(hiltRule).around(airplane).around(compose)
+
+ @Inject lateinit var db: HhoDatabase
+
+ @Inject lateinit var tripwire: NetworkTripwire
+
+ private sealed interface Screen {
+ data object List : Screen
+ data class Detail(val id: String) : Screen
+ data class Form(val id: String?, val visit: Int) : Screen
+ }
+
+ @Before
+ fun seed() {
+ hiltRule.inject()
+ runBlocking {
+ db.itemDao().upsertAll(
+ listOf(ItemEntity(DRILL, 1L, "Cordless Drill", "A cordless drill", null, 3L, "DR-1", 1L, 1L, 1L)),
+ )
+ }
+ compose.activityRule.scenario.onActivity { activity ->
+ val host = activity.findViewById(android.R.id.content).getChildAt(0) as ComposeView
+ host.setContent {
+ HhoTheme(darkTheme = false) {
+ var screen by remember { mutableStateOf(Screen.List) }
+ var visits by remember { mutableIntStateOf(0) }
+ when (val s = screen) {
+ Screen.List -> ItemListScreen(
+ onItemClick = { screen = Screen.Detail(it) },
+ onNewItem = { screen = Screen.Form(null, ++visits) },
+ )
+ is Screen.Detail -> ItemDetailScreen(
+ itemId = s.id,
+ onBack = { screen = Screen.List },
+ onEdit = { screen = Screen.Form(s.id, ++visits) },
+ )
+ is Screen.Form -> ItemFormScreen(
+ itemId = s.id,
+ viewModel = hiltViewModel(key = "form-${s.visit}"),
+ onSaved = { screen = Screen.Detail(it) },
+ onCancel = { screen = if (s.id == null) Screen.List else Screen.Detail(s.id) },
+ )
+ }
+ }
+ }
+ }
+ compose.waitForIdle()
+ }
+
+ @After
+ fun assertNoNetworkAttempted() {
+ assertEquals("network requests were attempted while offline", emptyList(), tripwire.requests.toList())
+ }
+
+ @Test
+ fun editAdjustAndCreateWorkOfflineAndQueueDistinctMutations() {
+ assertTrue("device is not offline", !airplane.hasInternet())
+
+ compose.onNodeWithText("Cordless Drill").performClick()
+ awaitText("Quantity: 3")
+ compose.onNodeWithText("Edit").performClick()
+ awaitText("Edit item")
+ compose.onNode(hasSetTextAction() and hasText("Name (required)")).performTextReplacement("Impact Driver")
+ compose.onNodeWithText("Save").performClick()
+ awaitText("Impact Driver")
+ shown("Impact Driver")
+
+ compose.onNodeWithText("Adjust quantity").performClick()
+ compose.onNodeWithText("+1").performClick()
+ compose.onNodeWithText("+1").performClick()
+ compose.onNodeWithText("Confirm").performClick()
+ awaitText("Quantity: 6")
+ shown("Quantity: 6")
+
+ compose.onNodeWithText("Back").performClick()
+ compose.onNodeWithText("New item").performClick()
+ awaitText("Identifier (optional)")
+ compose.onNode(hasSetTextAction() and hasText("Name (required)")).performTextInput("Label Maker")
+ compose.onNodeWithText("barcode").performScrollTo().performClick()
+ compose.onNode(hasSetTextAction() and hasText("Identifier value")).performScrollTo().performTextInput("4006381333931")
+ compose.onNodeWithText("Create").performScrollTo().performClick()
+ awaitText("Label Maker")
+ shown("Label Maker")
+ shown("barcode: 4006381333931")
+
+ val rows = runBlocking { db.outboxDao().getAllOrdered() }
+ assertEquals(4, rows.size)
+ assertEquals(listOf("item", "stock_adjustment", "item", "item_identification"), rows.map { it.entityType })
+ assertEquals(DRILL, rows[0].entityId)
+ assertEquals("mutation_ids must be distinct", 4, rows.map { it.mutationId }.toSet().size)
+ assertEquals(listOf("upsert", "upsert", "upsert", "upsert"), rows.map { it.op })
+ assertEquals("creates are based on version 0", listOf(0L, 0L, 0L), listOf(rows[1], rows[2], rows[3]).map { it.baseVersion })
+ assertTrue(rows[0].fieldsJson.contains("Impact Driver"))
+ assertTrue(rows[1].fieldsJson.contains("\"delta\""))
+ assertTrue(rows[3].fieldsJson.contains("4006381333931"))
+ assertTrue("no item mutation may carry quantity", rows.filter { it.entityType == "item" }.none { it.fieldsJson.contains("quantity") })
+ }
+
+ private fun awaitText(text: String) {
+ compose.waitUntil(WAIT_MILLIS) { compose.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() }
+ }
+
+ private fun shown(text: String) {
+ compose.onNodeWithText(text).performScrollTo().assertIsDisplayed()
+ }
+
+ private companion object {
+ const val DRILL = "item-1"
+ const val WAIT_MILLIS = 10_000L
+ }
+}
diff --git a/android-app/app/src/androidTest/kotlin/dev/hho/android/ui/items/OfflineItemsInstrumentedTest.kt b/android-app/app/src/androidTest/kotlin/dev/hho/android/ui/items/OfflineItemsInstrumentedTest.kt
new file mode 100644
index 0000000..ee74107
--- /dev/null
+++ b/android-app/app/src/androidTest/kotlin/dev/hho/android/ui/items/OfflineItemsInstrumentedTest.kt
@@ -0,0 +1,291 @@
+package dev.hho.android.ui.items
+
+import android.view.ViewGroup
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.hasSetTextAction
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dev.hho.android.data.room.HhoDatabase
+import dev.hho.android.data.room.ItemCustomFieldEntity
+import dev.hho.android.data.room.ItemEntity
+import dev.hho.android.data.room.ItemIdentificationEntity
+import dev.hho.android.data.room.ItemLabelEntity
+import dev.hho.android.data.room.LabelEntity
+import dev.hho.android.data.room.LocationEntity
+import dev.hho.android.data.room.PurchasedFromBlockEntity
+import dev.hho.android.data.room.SoldToBlockEntity
+import dev.hho.android.data.room.StockAdjustmentEntity
+import dev.hho.android.data.room.WarrantyBlockEntity
+import dev.hho.android.testing.AirplaneModeRule
+import dev.hho.android.MainActivity
+import dev.hho.android.testing.NetworkTripwire
+import dev.hho.android.ui.theme.HhoTheme
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import java.math.BigDecimal
+import java.time.LocalDate
+import javax.inject.Inject
+
+@HiltAndroidTest
+@RunWith(AndroidJUnit4::class)
+class OfflineItemsInstrumentedTest {
+
+ private val hiltRule = HiltAndroidRule(this)
+ private val airplane = AirplaneModeRule()
+ private val compose = createAndroidComposeRule()
+
+ @get:Rule
+ val chain: RuleChain = RuleChain.outerRule(hiltRule).around(airplane).around(compose)
+
+ @Inject lateinit var db: HhoDatabase
+
+ @Inject lateinit var tripwire: NetworkTripwire
+
+ @Before
+ fun seed() {
+ hiltRule.inject()
+ runBlocking { seedFixtures() }
+ compose.activityRule.scenario.onActivity { activity ->
+ val host = activity.findViewById(android.R.id.content).getChildAt(0) as ComposeView
+ host.setContent {
+ HhoTheme(darkTheme = false) {
+ var selected by rememberSaveable { mutableStateOf(null) }
+ val id = selected
+ if (id == null) {
+ ItemListScreen(onItemClick = { selected = it })
+ } else {
+ ItemDetailScreen(itemId = id, onBack = { selected = null })
+ }
+ }
+ }
+ }
+ compose.waitForIdle()
+ }
+
+ @After
+ fun assertNoNetworkAttempted() {
+ assertEquals("network requests were attempted while offline", emptyList(), tripwire.requests.toList())
+ }
+
+ @Test
+ fun deviceIsActuallyOffline() {
+ assertFalse("airplane mode did not take the device offline", airplane.hasInternet())
+ }
+
+ @Test
+ fun listShowsAllSeededItems() {
+ compose.onNodeWithText("Cordless Drill").assertExists()
+ compose.onNodeWithText("Garden Hose").assertExists()
+ compose.onNodeWithText("Stepladder").assertExists()
+ compose.onNodeWithText("Qty 3 · DR-1").assertExists()
+ }
+
+ @Test
+ fun searchByNameFilters() {
+ search("hose")
+ compose.onNodeWithText("Garden Hose").assertExists()
+ compose.onAllNodesWithText("Cordless Drill").assertCountEquals(0)
+ compose.onAllNodesWithText("Stepladder").assertCountEquals(0)
+ }
+
+ @Test
+ fun searchByIdentifierValueFilters() {
+ search("SN-DRILL")
+ compose.onNodeWithText("Cordless Drill").assertExists()
+ compose.onAllNodesWithText("Garden Hose").assertCountEquals(0)
+ compose.onAllNodesWithText("Stepladder").assertCountEquals(0)
+ }
+
+ @Test
+ fun searchWithNoMatchShowsNoMatchesState() {
+ search("zzz-nothing")
+ compose.onNodeWithText("No items, short codes, or scanned values match \"zzz-nothing\".").assertExists()
+ compose.onAllNodesWithText("Cordless Drill").assertCountEquals(0)
+ }
+
+ @Test
+ fun detailShowsCoreFieldsLocationAndLabels() {
+ openDrill()
+ shown("A cordless drill")
+ shown("Quantity: 3")
+ shown("Short code: DR-1")
+ shown("Location: Garage")
+ shown("Labels: Power tools, Fragile")
+ }
+
+ @Test
+ fun detailShowsWarrantySoldToAndPurchasedFromBlocks() {
+ openDrill()
+ shown("Warranty")
+ shown("Holder: Jane Doe")
+ shown("Provider: Acme Warranties")
+ shown("Starts: 2024-01-01")
+ shown("Expires: 2026-01-01")
+ shown("Sold to")
+ shown("Sale price (minor units): 5000")
+ shown("Buyer: Alex Buyer")
+ shown("Purchased from")
+ shown("Purchase price (minor units): 12000")
+ shown("Vendor: Hardware Store")
+ shown("Order reference: PO-42")
+ }
+
+ @Test
+ fun detailShowsIdentifiersAndEveryCustomFieldType() {
+ openDrill()
+ shown("Identifiers")
+ shown("barcode: 0123456789012")
+ shown("serial: SN-DRILL-77")
+ shown("Custom fields")
+ shown("Colour: Red")
+ shown("Voltage: 18.5")
+ shown("Cordless: Yes")
+ shown("Bought: 2023-03-01")
+ }
+
+ @Test
+ fun detailShowsStockHistory() {
+ openDrill()
+ shown("Stock history")
+ shown("+5 → 5 (initial)")
+ shown("-2 → 3 (loan)")
+ shown("Note: lent to a neighbour")
+ }
+
+ @Test
+ fun richItemLastStockRowIsReachableByScrolling() {
+ compose.onNode(hasText("Server Rack")).performClick()
+ compose.waitUntil(WAIT_MILLIS) {
+ compose.onAllNodesWithText("Short code: RACK-9").fetchSemanticsNodes().isNotEmpty()
+ }
+ shown("Note: adjustment note 30")
+ shown("+1 → 30 (adjust 30)")
+ shown("rack-id-1: RACK-VAL-1")
+ }
+
+ @Test
+ fun backFromDetailReturnsToList() {
+ openDrill()
+ compose.onNodeWithText("Back").performClick()
+ compose.onNodeWithText("Garden Hose").assertExists()
+ compose.onNodeWithText("Stepladder").assertExists()
+ }
+
+ @Test
+ fun itemWithoutBlocksOmitsThem() {
+ compose.onNodeWithText("Stepladder").performClick()
+ compose.onNodeWithText("Back").assertExists()
+ compose.onAllNodesWithText("Warranty").assertCountEquals(0)
+ compose.onAllNodesWithText("Identifiers").assertCountEquals(0)
+ }
+
+ private fun shown(text: String) {
+ compose.onNodeWithText(text).performScrollTo().assertIsDisplayed()
+ }
+
+ private fun search(query: String) {
+ compose.onNode(hasSetTextAction()).performTextInput(query)
+ compose.waitForIdle()
+ }
+
+ private fun openDrill() {
+ compose.onNode(hasText("Cordless Drill")).performClick()
+ compose.waitUntil(WAIT_MILLIS) {
+ compose.onAllNodesWithText("Location: Garage").fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private suspend fun seedFixtures() {
+ db.locationDao().upsert(LocationEntity("loc-1", 1L, "Garage", null, 1L, 1L, 1L))
+ db.labelDao().upsert(LabelEntity("label-1", 1L, "Power tools", "#ffffff", 1L, 1L, 1L))
+ db.labelDao().upsert(LabelEntity("label-2", 1L, "Fragile", "#ffffff", 1L, 1L, 1L))
+ db.itemDao().upsertAll(
+ listOf(
+ item("item-1", "Cordless Drill", locationId = "loc-1", quantity = 3L, shortCode = "DR-1", description = "A cordless drill"),
+ item("item-2", "Garden Hose", quantity = 1L, shortCode = "GH-2", description = null),
+ item("item-4", "Server Rack", quantity = 30L, shortCode = "RACK-9", description = null),
+ item("item-3", "Stepladder", quantity = null, shortCode = null, description = null),
+ ),
+ )
+ db.itemLabelDao().upsert(ItemLabelEntity("il-1", 1L, "item-1", "label-1"))
+ db.itemLabelDao().upsert(ItemLabelEntity("il-2", 1L, "item-1", "label-2"))
+ db.warrantyBlockDao().upsert(
+ WarrantyBlockEntity(
+ "w-1", 1L, "item-1", false, "Jane Doe", "Acme Warranties",
+ LocalDate.of(2024, 1, 1), LocalDate.of(2026, 1, 1), "Keep receipt", 1L, 1L, 1L,
+ ),
+ )
+ db.soldToBlockDao().upsert(
+ SoldToBlockEntity("s-1", 1L, "item-1", 5_000L, "Alex Buyer", LocalDate.of(2024, 6, 1), "Yard sale", 1L, 1L, 1L),
+ )
+ db.purchasedFromBlockDao().upsert(
+ PurchasedFromBlockEntity(
+ "p-1", 1L, "item-1", 12_000L, "Hardware Store", LocalDate.of(2023, 3, 1), "PO-42", "On sale", 1L, 1L, 1L,
+ ),
+ )
+ db.itemIdentificationDao().upsert(identifier("id-1", "item-1", "barcode", "0123456789012"))
+ db.itemIdentificationDao().upsert(identifier("id-2", "item-1", "serial", "SN-DRILL-77"))
+ db.itemIdentificationDao().upsert(identifier("id-3", "item-2", "barcode", "9998887776665"))
+ db.itemCustomFieldDao().upsert(customField("cf-1", "Colour", "text", textValue = "Red"))
+ db.itemCustomFieldDao().upsert(customField("cf-2", "Voltage", "number", numberValue = BigDecimal("18.5")))
+ db.itemCustomFieldDao().upsert(customField("cf-3", "Cordless", "boolean", boolValue = true))
+ db.itemCustomFieldDao().upsert(customField("cf-4", "Bought", "date", dateValue = LocalDate.of(2023, 3, 1)))
+ for (n in 1..RICH_ROWS) {
+ db.itemIdentificationDao().upsert(identifier("rid-$n", "item-4", "rack-id-$n", "RACK-VAL-$n"))
+ db.itemCustomFieldDao().upsert(customField("rcf-$n", "Field $n", "text", textValue = "Value $n", itemId = "item-4"))
+ db.stockAdjustmentDao().upsert(
+ StockAdjustmentEntity("rsa-$n", 1L, "item-4", 1L, n.toLong(), "adjust $n", "adjustment note $n", n.toLong(), n.toLong(), 1L),
+ )
+ }
+ db.stockAdjustmentDao().upsert(stock("sa-1", 5L, 5L, "initial", null, 1L))
+ db.stockAdjustmentDao().upsert(stock("sa-2", -2L, 3L, "loan", "lent to a neighbour", 2L))
+ }
+
+ private fun item(id: String, name: String, locationId: String? = null, quantity: Long?, shortCode: String?, description: String?) =
+ ItemEntity(id, 1L, name, description, locationId, quantity, shortCode, 1L, 1L, 1L)
+
+ private fun identifier(id: String, itemId: String, kind: String, value: String) =
+ ItemIdentificationEntity(id, 1L, itemId, kind, value, 1L, 1L, 1L)
+
+ private fun customField(
+ id: String,
+ name: String,
+ type: String,
+ textValue: String? = null,
+ numberValue: BigDecimal? = null,
+ boolValue: Boolean? = null,
+ dateValue: LocalDate? = null,
+ itemId: String = "item-1",
+ ) = ItemCustomFieldEntity(
+ id, 1L, itemId, name, type, null, textValue, numberValue, boolValue, dateValue, 1L, 1L, 1L,
+ )
+
+ private fun stock(id: String, delta: Long, resulting: Long, reason: String?, note: String?, at: Long) =
+ StockAdjustmentEntity(id, 1L, "item-1", delta, resulting, reason, note, at, at, 1L)
+
+ private companion object {
+ const val WAIT_MILLIS = 10_000L
+ const val RICH_ROWS = 30
+ }
+}
diff --git a/android-app/app/src/foss/kotlin/dev/hho/android/di/ScannerModule.kt b/android-app/app/src/foss/kotlin/dev/hho/android/di/ScannerModule.kt
new file mode 100644
index 0000000..38c7ab7
--- /dev/null
+++ b/android-app/app/src/foss/kotlin/dev/hho/android/di/ScannerModule.kt
@@ -0,0 +1,18 @@
+package dev.hho.android.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dev.hho.android.data.scanner.BarcodeScanner
+import dev.hho.android.data.scanner.ZxingBarcodeScanner
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class ScannerModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindBarcodeScanner(impl: ZxingBarcodeScanner): BarcodeScanner
+}
diff --git a/android-app/app/src/gms/kotlin/dev/hho/android/data/scanner/MlKitBarcodeScanner.kt b/android-app/app/src/gms/kotlin/dev/hho/android/data/scanner/MlKitBarcodeScanner.kt
new file mode 100644
index 0000000..c0cac02
--- /dev/null
+++ b/android-app/app/src/gms/kotlin/dev/hho/android/data/scanner/MlKitBarcodeScanner.kt
@@ -0,0 +1,57 @@
+package dev.hho.android.data.scanner
+
+import androidx.camera.core.ImageProxy
+import com.google.android.gms.tasks.Task
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import kotlinx.coroutines.suspendCancellableCoroutine
+import javax.inject.Inject
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+class MlKitBarcodeScanner
+ @Inject
+ constructor() : BarcodeScanner {
+
+ private val client: com.google.mlkit.vision.barcode.BarcodeScanner by lazy { BarcodeScanning.getClient() }
+
+ override suspend fun scan(imageProxy: ImageProxy): List {
+ val mediaImage = imageProxy.image ?: return emptyList()
+ val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+ val barcodes = client.process(inputImage).await()
+ return barcodes.mapNotNull { mapMlKitBarcode(it.rawValue, it.format) }
+ }
+
+ override fun close() {
+ client.close()
+ }
+ }
+
+internal fun mapMlKitBarcode(rawValue: String?, mlKitFormat: Int): DecodedBarcode? =
+ rawValue?.let { DecodedBarcode(it, mapMlKitFormat(mlKitFormat)) }
+
+internal fun mapMlKitFormat(mlKitFormat: Int): BarcodeFormat =
+ when (mlKitFormat) {
+ Barcode.FORMAT_QR_CODE -> BarcodeFormat.QR_CODE
+ Barcode.FORMAT_AZTEC -> BarcodeFormat.AZTEC
+ Barcode.FORMAT_DATA_MATRIX -> BarcodeFormat.DATA_MATRIX
+ Barcode.FORMAT_PDF417 -> BarcodeFormat.PDF_417
+ Barcode.FORMAT_CODE_39 -> BarcodeFormat.CODE_39
+ Barcode.FORMAT_CODE_93 -> BarcodeFormat.CODE_93
+ Barcode.FORMAT_CODE_128 -> BarcodeFormat.CODE_128
+ Barcode.FORMAT_CODABAR -> BarcodeFormat.CODABAR
+ Barcode.FORMAT_ITF -> BarcodeFormat.ITF
+ Barcode.FORMAT_EAN_8 -> BarcodeFormat.EAN_8
+ Barcode.FORMAT_EAN_13 -> BarcodeFormat.EAN_13
+ Barcode.FORMAT_UPC_A -> BarcodeFormat.UPC_A
+ Barcode.FORMAT_UPC_E -> BarcodeFormat.UPC_E
+ else -> BarcodeFormat.UNKNOWN
+ }
+
+private suspend fun Task.await(): T =
+ suspendCancellableCoroutine { continuation ->
+ addOnSuccessListener { continuation.resume(it) }
+ addOnFailureListener { continuation.resumeWithException(it) }
+ addOnCanceledListener { continuation.cancel() }
+ }
diff --git a/android-app/app/src/gms/kotlin/dev/hho/android/di/ScannerModule.kt b/android-app/app/src/gms/kotlin/dev/hho/android/di/ScannerModule.kt
new file mode 100644
index 0000000..2268220
--- /dev/null
+++ b/android-app/app/src/gms/kotlin/dev/hho/android/di/ScannerModule.kt
@@ -0,0 +1,18 @@
+package dev.hho.android.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dev.hho.android.data.scanner.BarcodeScanner
+import dev.hho.android.data.scanner.MlKitBarcodeScanner
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class ScannerModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindBarcodeScanner(impl: MlKitBarcodeScanner): BarcodeScanner
+}
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..192f07f
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/HhoApplication.kt b/android-app/app/src/main/kotlin/dev/hho/android/HhoApplication.kt
new file mode 100644
index 0000000..9100bd7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/HhoApplication.kt
@@ -0,0 +1,26 @@
+package dev.hho.android
+
+import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import dagger.hilt.android.HiltAndroidApp
+import dev.hho.android.data.sync.SyncScheduler
+import javax.inject.Inject
+
+@HiltAndroidApp
+class HhoApplication : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var hiltWorkerFactory: HiltWorkerFactory
+
+ @Inject
+ lateinit var syncScheduler: SyncScheduler
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder().setWorkerFactory(hiltWorkerFactory).build()
+
+ override fun onCreate() {
+ super.onCreate()
+ syncScheduler.schedulePeriodic()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/MainActivity.kt b/android-app/app/src/main/kotlin/dev/hho/android/MainActivity.kt
new file mode 100644
index 0000000..51fd89d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/MainActivity.kt
@@ -0,0 +1,47 @@
+package dev.hho.android
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import dagger.hilt.android.AndroidEntryPoint
+import dev.hho.android.ui.HhoApp
+import dev.hho.android.ui.deeplink.ItemDeepLink
+import dev.hho.android.ui.deeplink.parseItemDeepLink
+import dev.hho.android.ui.theme.HhoTheme
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ private var pendingDeepLink by mutableStateOf(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ if (savedInstanceState == null) captureDeepLink(intent)
+ setContent {
+ HhoTheme {
+ HhoApp(
+ pendingDeepLink = pendingDeepLink,
+ onDeepLinkHandled = { pendingDeepLink = null },
+ )
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ captureDeepLink(intent)
+ }
+
+ private fun captureDeepLink(intent: Intent?) {
+ if (intent?.action == Intent.ACTION_VIEW) {
+ parseItemDeepLink(intent.dataString)?.let { pendingDeepLink = it }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/ApiError.kt b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/ApiError.kt
new file mode 100644
index 0000000..85bedda
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/ApiError.kt
@@ -0,0 +1,34 @@
+package dev.hho.android.data.apiclient
+
+sealed class ApiError(message: String, cause: Throwable? = null) : Exception(message, cause) {
+ class NoInstanceConfigured(cause: Throwable) :
+ ApiError("No self-hosted instance is configured yet", cause)
+
+ class Unauthorized(cause: Throwable? = null) : ApiError("Not authenticated", cause)
+
+ class NotFound(cause: Throwable? = null) : ApiError("Not found", cause)
+
+ class Conflict(val detail: String?, cause: Throwable? = null) :
+ ApiError("Conflict" + (detail?.let { ": $it" } ?: ""), cause)
+
+ open class Validation(val status: Int, val detail: String?, cause: Throwable? = null) :
+ ApiError("Client error $status" + (detail?.let { ": $it" } ?: ""), cause)
+
+ class PayloadTooLarge(detail: String?, cause: Throwable? = null) : Validation(413, detail, cause)
+
+ class UpgradeRequired(val minimumVersion: String, val detail: String?, cause: Throwable? = null) :
+ ApiError(
+ "Client too old — server requires at least $minimumVersion" + (detail?.let { ": $it" } ?: ""),
+ cause,
+ )
+
+ class Server(val status: Int, val detail: String?, cause: Throwable? = null) :
+ ApiError("Server error $status" + (detail?.let { ": $it" } ?: ""), cause)
+
+ class Network(cause: Throwable) : ApiError("Network failure", cause)
+
+ class UnexpectedPayload(val detail: String, cause: Throwable? = null) :
+ ApiError("Unexpected response payload: $detail", cause)
+
+ class Unknown(cause: Throwable) : ApiError(cause.message ?: "Unexpected error", cause)
+}
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/AttachmentInfo.kt b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/AttachmentInfo.kt
new file mode 100644
index 0000000..0aa25df
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/AttachmentInfo.kt
@@ -0,0 +1,46 @@
+package dev.hho.android.data.apiclient
+
+import dev.hho.android.data.apiclient.generated.apis.AttachmentsApi
+import dev.hho.android.data.apiclient.generated.models.Attachment
+
+enum class AttachmentCategory(val wire: String) {
+ IMAGE("image"),
+ MANUAL("manual"),
+ WARRANTY("warranty"),
+ RECEIPT("receipt"),
+ GENERAL("general"),
+}
+
+data class AttachmentInfo(
+ val id: String,
+ val itemId: String,
+ val category: String,
+ val originalFilename: String,
+ val contentType: String,
+ val sizeBytes: Long,
+ val sha256: String,
+ val createdAt: Long,
+ val hasThumbnail: Boolean,
+)
+
+internal fun AttachmentCategory.toGenerated(): AttachmentsApi.CategoryUploadItemAttachment =
+ when (this) {
+ AttachmentCategory.IMAGE -> AttachmentsApi.CategoryUploadItemAttachment.Image
+ AttachmentCategory.MANUAL -> AttachmentsApi.CategoryUploadItemAttachment.Manual
+ AttachmentCategory.WARRANTY -> AttachmentsApi.CategoryUploadItemAttachment.Warranty
+ AttachmentCategory.RECEIPT -> AttachmentsApi.CategoryUploadItemAttachment.Receipt
+ AttachmentCategory.GENERAL -> AttachmentsApi.CategoryUploadItemAttachment.General
+ }
+
+internal fun Attachment.toInfo(): AttachmentInfo =
+ AttachmentInfo(
+ id = id,
+ itemId = itemId,
+ category = category.value,
+ originalFilename = originalFilename,
+ contentType = contentType,
+ sizeBytes = sizeBytes,
+ sha256 = sha256,
+ createdAt = createdAt,
+ hasThumbnail = hasThumbnail,
+ )
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/HhoApiClient.kt b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/HhoApiClient.kt
new file mode 100644
index 0000000..c2d27eb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/HhoApiClient.kt
@@ -0,0 +1,328 @@
+package dev.hho.android.data.apiclient
+
+import dev.hho.android.data.apiclient.generated.apis.AttachmentsApi
+import dev.hho.android.data.apiclient.generated.apis.AuthApi
+import dev.hho.android.data.apiclient.generated.apis.StatusApi
+import dev.hho.android.data.apiclient.generated.apis.SyncApi
+import dev.hho.android.data.apiclient.generated.infrastructure.ApiResponse
+import dev.hho.android.data.apiclient.generated.infrastructure.ClientError
+import dev.hho.android.data.apiclient.generated.infrastructure.ClientException
+import dev.hho.android.data.apiclient.generated.infrastructure.ResponseType
+import dev.hho.android.data.apiclient.generated.infrastructure.Serializer
+import dev.hho.android.data.apiclient.generated.infrastructure.ServerError
+import dev.hho.android.data.apiclient.generated.infrastructure.ServerException
+import dev.hho.android.data.apiclient.generated.infrastructure.Success
+import dev.hho.android.data.apiclient.generated.infrastructure.Response as GeneratedResponse
+import dev.hho.android.data.apiclient.generated.models.DeviceTokenIssueRequest
+import dev.hho.android.data.apiclient.generated.models.DeviceTokenIssueResponse
+import dev.hho.android.data.apiclient.generated.models.LoginRequest
+import dev.hho.android.data.apiclient.generated.models.LoginResponse
+import dev.hho.android.data.apiclient.generated.models.Problem
+import dev.hho.android.data.apiclient.generated.models.StatusResponse
+import dev.hho.android.data.apiclient.generated.models.SyncConflictLogResponse
+import dev.hho.android.data.apiclient.generated.models.SyncPullRequest
+import dev.hho.android.data.apiclient.generated.models.SyncPushResponse
+import dev.hho.android.data.apiclient.generated.models.UpgradeRequiredProblem
+import dev.hho.android.data.network.AuthHeaderInterceptor
+import dev.hho.android.data.network.NoInstanceUrlConfiguredException
+import dev.hho.android.di.ProbeHttpClient
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.put
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val PLACEHOLDER_BASE_URL = "http://placeholder.invalid/api/v1"
+
+private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+
+private const val SESSION_COOKIE_NAME = "hho_session"
+
+data class LoginOutcome(
+ val response: LoginResponse,
+ val sessionCookie: String,
+)
+
+@Singleton
+class HhoApiClient
+ @Inject
+ constructor(
+ private val httpClient: OkHttpClient,
+ @ProbeHttpClient private val probeHttpClient: OkHttpClient,
+ ) {
+ private val statusApi = StatusApi(PLACEHOLDER_BASE_URL, httpClient)
+ private val authApi = AuthApi(PLACEHOLDER_BASE_URL, httpClient)
+ private val attachmentsApi = AttachmentsApi(PLACEHOLDER_BASE_URL, httpClient)
+ private val syncApi = SyncApi(PLACEHOLDER_BASE_URL, httpClient)
+
+ suspend fun getStatus(): Result = apiCall { statusApi.getStatus() }
+
+ suspend fun probeInstance(candidateBaseUrl: HttpUrl): Result =
+ apiCall {
+ val probeBasePath = candidateBaseUrl.newBuilder().addPathSegments("api/v1").build().toString()
+ StatusApi(probeBasePath, probeHttpClient).getStatus()
+ }
+
+ suspend fun login(
+ username: String,
+ password: String,
+ ): Result =
+ apiCall {
+ val httpResponse = authApi.loginWithHttpInfo(LoginRequest(username = username, password = password), null)
+ val body = httpResponse.bodyOrThrow()
+ val sessionCookie = httpResponse.headers.sessionCookieValue()
+ ?: throw MissingSessionCookieException()
+ LoginOutcome(response = body, sessionCookie = sessionCookie)
+ }
+
+ suspend fun issueDeviceToken(
+ deviceLabel: String,
+ sessionCookie: String,
+ ): Result =
+ apiCall {
+ val cookieScopedClient =
+ httpClient.newBuilder()
+ .apply { interceptors().removeAll { it is AuthHeaderInterceptor } }
+ .addInterceptor { chain ->
+ chain.proceed(chain.request().newBuilder().header("Cookie", sessionCookie).build())
+ }
+ .build()
+ AuthApi(PLACEHOLDER_BASE_URL, cookieScopedClient)
+ .issueDeviceToken(DeviceTokenIssueRequest(deviceLabel = deviceLabel))
+ }
+
+ suspend fun downloadAttachment(
+ itemId: String,
+ attachmentId: String,
+ ): Result =
+ apiCall {
+ attachmentsApi.downloadItemAttachment(itemID = itemId, attachmentID = attachmentId)
+ }
+
+ suspend fun uploadAttachment(
+ itemId: String,
+ category: AttachmentCategory,
+ file: File,
+ ): Result =
+ apiCall {
+ attachmentsApi
+ .uploadItemAttachment(itemID = itemId, category = category.toGenerated(), file = file)
+ .toInfo()
+ }
+
+ suspend fun listAttachments(itemId: String): Result> =
+ apiCall {
+ attachmentsApi.listItemAttachments(itemID = itemId).attachments.map { it.toInfo() }
+ }
+
+ suspend fun syncPull(
+ deviceId: String,
+ since: Long,
+ limit: Int,
+ ): Result =
+ apiCall {
+ val requestJson =
+ Serializer.kotlinxSerializationJson.encodeToString(
+ SyncPullRequest(deviceId = deviceId, since = since, limit = limit),
+ )
+ val request =
+ Request.Builder()
+ .url(PLACEHOLDER_BASE_URL.toHttpUrl().newBuilder().addPathSegments("sync/pull").build())
+ .post(requestJson.toRequestBody(JSON_MEDIA_TYPE))
+ .header("Accept", "application/json, application/problem+json")
+ .build()
+
+ httpClient.newCall(request).execute().use { response ->
+ val bodyString = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ throw httpStatusToApiError(response.code, bodyString)
+ }
+ SyncChangeDecoder.decodePullResponse(
+ Serializer.kotlinxSerializationJson.parseToJsonElement(bodyString),
+ )
+ }
+ }
+
+ suspend fun syncPush(
+ deviceId: String,
+ mutations: List,
+ ): Result =
+ apiCall {
+ val requestJson = encodePushRequest(deviceId, mutations)
+ val request =
+ Request.Builder()
+ .url(PLACEHOLDER_BASE_URL.toHttpUrl().newBuilder().addPathSegments("sync/push").build())
+ .post(requestJson.toRequestBody(JSON_MEDIA_TYPE))
+ .header("Accept", "application/json, application/problem+json")
+ .build()
+
+ httpClient.newCall(request).execute().use { response ->
+ val bodyString = response.body?.string().orEmpty()
+ if (response.code == 400) {
+ val detail =
+ runCatching { Serializer.kotlinxSerializationJson.decodeFromString(bodyString).detail }.getOrNull()
+ if (detail != null && detail.startsWith(SyncPushDetailParser.STRUCTURAL_PREFIX)) {
+ return@use SyncPushDetailParser.parse(detail)
+ }
+ }
+ if (!response.isSuccessful) {
+ throw httpStatusToApiError(response.code, bodyString)
+ }
+ val decoded = Serializer.kotlinxSerializationJson.decodeFromString(bodyString)
+ SyncPushOutcome.Success(decoded.applied, decoded.skipped, decoded.conflicts, decoded.newWatermark)
+ }
+ }
+
+ suspend fun getConflicts(
+ after: String? = null,
+ limit: Int = 50,
+ ): Result = apiCall { syncApi.listSyncConflicts(limit = limit, after = after) }
+
+ private suspend fun apiCall(block: () -> T): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ Result.success(block())
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ Result.failure(e.toApiError())
+ }
+ }
+ }
+
+private fun encodePushRequest(
+ deviceId: String,
+ mutations: List,
+): String {
+ val root: JsonObject =
+ buildJsonObject {
+ put("device_id", deviceId)
+ put(
+ "mutations",
+ JsonArray(
+ mutations.map { m ->
+ buildJsonObject {
+ put("mutation_id", m.mutationId)
+ put("entity_type", m.entityType)
+ put("entity_id", m.entityId)
+ put("base_version", m.baseVersion)
+ put("fields", JsonObject(m.fields))
+ m.op?.let { put("op", JsonPrimitive(it)) }
+ }
+ },
+ ),
+ )
+ }
+ return root.toString()
+}
+
+private fun Throwable.toApiError(): ApiError =
+ when (this) {
+ is ApiError -> this
+ is NoInstanceUrlConfiguredException -> ApiError.NoInstanceConfigured(this)
+ is ClientException -> toApiError()
+ is ServerException -> ApiError.Server(statusCode, problemDetailOf(response), this)
+ is MissingSessionCookieException -> ApiError.UnexpectedPayload(message ?: toString(), this)
+ is UnknownSyncEntityTypeException -> ApiError.UnexpectedPayload(message ?: toString(), this)
+ is MalformedSyncPullResponseException -> ApiError.UnexpectedPayload(message ?: toString(), this)
+ is SerializationException -> ApiError.UnexpectedPayload(message ?: "malformed response body", this)
+ is UnsupportedOperationException -> ApiError.UnexpectedPayload(message ?: "unsupported response content type", this)
+ is IOException -> ApiError.Network(this)
+ else -> ApiError.Unknown(this)
+ }
+
+private fun ClientException.toApiError(): ApiError {
+ if (statusCode == 426) {
+ upgradeRequiredProblemOf(response)?.let { return ApiError.UpgradeRequired(it.minimumVersion, it.detail, this) }
+ }
+ val detail = problemDetailOf(response)
+ return when (statusCode) {
+ 401 -> ApiError.Unauthorized(this)
+ 404 -> ApiError.NotFound(this)
+ 413 -> ApiError.PayloadTooLarge(detail, this)
+ 409 -> ApiError.Conflict(detail, this)
+ else -> ApiError.Validation(statusCode, detail, this)
+ }
+}
+
+private fun httpStatusToApiError(
+ statusCode: Int,
+ rawBody: String,
+): ApiError {
+ if (statusCode == 426) {
+ runCatching { Serializer.kotlinxSerializationJson.decodeFromString(rawBody) }
+ .getOrNull()
+ ?.let { return ApiError.UpgradeRequired(it.minimumVersion, it.detail) }
+ }
+ val detail = runCatching { Serializer.kotlinxSerializationJson.decodeFromString(rawBody).detail }.getOrNull()
+ return when (statusCode) {
+ 401 -> ApiError.Unauthorized()
+ 404 -> ApiError.NotFound()
+ 413 -> ApiError.PayloadTooLarge(detail)
+ 409 -> ApiError.Conflict(detail)
+ in 500..599 -> ApiError.Server(statusCode, detail)
+ else -> ApiError.Validation(statusCode, detail)
+ }
+}
+
+private fun upgradeRequiredProblemOf(response: GeneratedResponse?): UpgradeRequiredProblem? {
+ val rawBody =
+ when (response) {
+ is ClientError<*> -> response.body as? String
+ is ServerError<*> -> response.body as? String
+ else -> null
+ } ?: return null
+ return runCatching { Serializer.kotlinxSerializationJson.decodeFromString(rawBody) }.getOrNull()
+}
+
+private fun problemDetailOf(response: GeneratedResponse?): String? {
+ val rawBody =
+ when (response) {
+ is ClientError<*> -> response.body as? String
+ is ServerError<*> -> response.body as? String
+ else -> null
+ } ?: return null
+ return runCatching { Serializer.kotlinxSerializationJson.decodeFromString(rawBody).detail }.getOrNull()
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun ApiResponse.bodyOrThrow(): T =
+ when (responseType) {
+ ResponseType.Success -> (this as Success<*>).data as T
+ ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.")
+ ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.")
+ ResponseType.ClientError -> {
+ val error = this as ClientError<*>
+ throw ClientException("Client error : ${error.statusCode} ${error.message.orEmpty()}", error.statusCode, this)
+ }
+ ResponseType.ServerError -> {
+ val error = this as ServerError<*>
+ throw ServerException("Server error : ${error.statusCode} ${error.message.orEmpty()} ${error.body}", error.statusCode, this)
+ }
+ }
+
+private fun Map>.sessionCookieValue(): String? =
+ entries
+ .firstOrNull { it.key.equals("Set-Cookie", ignoreCase = true) }
+ ?.value
+ ?.map { it.substringBefore(';') }
+ ?.firstOrNull { it.startsWith("$SESSION_COOKIE_NAME=") }
+
+private class MissingSessionCookieException :
+ Exception("Login succeeded but the response carried no $SESSION_COOKIE_NAME session cookie")
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/SyncChange.kt b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/SyncChange.kt
new file mode 100644
index 0000000..af6622a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/SyncChange.kt
@@ -0,0 +1,213 @@
+package dev.hho.android.data.apiclient
+
+import dev.hho.android.data.apiclient.generated.infrastructure.Serializer
+import dev.hho.android.data.apiclient.generated.models.Attachment
+import dev.hho.android.data.apiclient.generated.models.Identification
+import dev.hho.android.data.apiclient.generated.models.Item
+import dev.hho.android.data.apiclient.generated.models.ItemCustomField
+import dev.hho.android.data.apiclient.generated.models.ItemLabelAssignment
+import dev.hho.android.data.apiclient.generated.models.Label
+import dev.hho.android.data.apiclient.generated.models.Location
+import dev.hho.android.data.apiclient.generated.models.PurchaseBlock
+import dev.hho.android.data.apiclient.generated.models.SaleBlock
+import dev.hho.android.data.apiclient.generated.models.StockAdjustment
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryAttachment
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryItem
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryItemCustomField
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryItemIdentification
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryItemLabel
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryLabel
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryLocation
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryPurchasedFromBlock
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntrySoldToBlock
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryStockAdjustment
+import dev.hho.android.data.apiclient.generated.models.SyncChangeEntryWarrantyBlock
+import dev.hho.android.data.apiclient.generated.models.SyncTombstoneEntry
+import dev.hho.android.data.apiclient.generated.models.WarrantyBlock
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+
+sealed interface SyncChange {
+ val id: String
+
+ val groupChangeSeq: Long
+
+ data class ItemChange(override val id: String, override val groupChangeSeq: Long, val data: Item) : SyncChange
+
+ data class WarrantyBlockChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: WarrantyBlock,
+ ) : SyncChange
+
+ data class SoldToBlockChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: SaleBlock,
+ ) : SyncChange
+
+ data class PurchasedFromBlockChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: PurchaseBlock,
+ ) : SyncChange
+
+ data class ItemIdentificationChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: Identification,
+ ) : SyncChange
+
+ data class ItemCustomFieldChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: ItemCustomField,
+ ) : SyncChange
+
+ data class StockAdjustmentChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: StockAdjustment,
+ ) : SyncChange
+
+ data class LocationChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: Location,
+ ) : SyncChange
+
+ data class LabelChange(override val id: String, override val groupChangeSeq: Long, val data: Label) : SyncChange
+
+ data class ItemLabelChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: ItemLabelAssignment,
+ ) : SyncChange
+
+ data class AttachmentChange(
+ override val id: String,
+ override val groupChangeSeq: Long,
+ val data: Attachment,
+ ) : SyncChange
+}
+
+sealed interface SyncPullOutcome {
+ data class Page(
+ val changes: List,
+ val tombstones: List,
+ val nextWatermark: Long,
+ val hasMore: Boolean,
+ ) : SyncPullOutcome
+
+ data object CursorTooOld : SyncPullOutcome
+}
+
+class UnknownSyncEntityTypeException(val rawEntityType: String?) :
+ IllegalStateException("Unknown sync entity_type: $rawEntityType — client/server drift (A7 closed set)")
+
+class MalformedSyncPullResponseException(detail: String, cause: Throwable? = null) :
+ IllegalStateException("Malformed /sync/pull response: $detail", cause)
+
+internal object SyncChangeDecoder {
+
+ private val json = Serializer.kotlinxSerializationJson
+
+ fun decodePullResponse(root: JsonElement): SyncPullOutcome {
+ val obj =
+ root as? JsonObject
+ ?: throw MalformedSyncPullResponseException("expected a JSON object, got $root")
+
+ if (obj["cursor_too_old"]?.jsonPrimitive?.booleanOrNull == true) {
+ return SyncPullOutcome.CursorTooOld
+ }
+
+ try {
+ val changesJson =
+ obj["changes"] as? JsonArray
+ ?: throw MalformedSyncPullResponseException("missing or non-array 'changes'")
+ val tombstonesJson =
+ obj["tombstones"] as? JsonArray
+ ?: throw MalformedSyncPullResponseException("missing or non-array 'tombstones'")
+ val nextWatermark =
+ obj["next_watermark"]?.jsonPrimitive?.longOrNull
+ ?: throw MalformedSyncPullResponseException("missing or non-numeric 'next_watermark'")
+ val hasMore =
+ obj["has_more"]?.jsonPrimitive?.booleanOrNull
+ ?: throw MalformedSyncPullResponseException("missing or non-boolean 'has_more'")
+
+ return SyncPullOutcome.Page(
+ changes = changesJson.map { decodeChange(it) },
+ tombstones = tombstonesJson.map { json.decodeFromJsonElement(it) },
+ nextWatermark = nextWatermark,
+ hasMore = hasMore,
+ )
+ } catch (e: SerializationException) {
+ throw MalformedSyncPullResponseException(e.message ?: "decode failure", e)
+ }
+ }
+
+ fun decodeChange(element: JsonElement): SyncChange {
+ val entityType =
+ (element as? JsonObject)?.get("entity_type")?.jsonPrimitive?.contentOrNull
+ ?: throw UnknownSyncEntityTypeException(rawEntityType = null)
+
+ return try {
+ when (entityType) {
+ "item" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.ItemChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "warranty_block" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.WarrantyBlockChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "sold_to_block" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.SoldToBlockChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "purchased_from_block" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.PurchasedFromBlockChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "item_identification" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.ItemIdentificationChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "item_custom_field" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.ItemCustomFieldChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "stock_adjustment" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.StockAdjustmentChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "location" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.LocationChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "label" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.LabelChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "item_label" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.ItemLabelChange(it.id, it.groupChangeSeq, it.data)
+ }
+ "attachment" ->
+ json.decodeFromJsonElement(element).let {
+ SyncChange.AttachmentChange(it.id, it.groupChangeSeq, it.data)
+ }
+ else -> throw UnknownSyncEntityTypeException(entityType)
+ }
+ } catch (e: SerializationException) {
+ throw MalformedSyncPullResponseException("entity_type=$entityType: ${e.message}", e)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/SyncPushOutcome.kt b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/SyncPushOutcome.kt
new file mode 100644
index 0000000..4196166
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/SyncPushOutcome.kt
@@ -0,0 +1,47 @@
+package dev.hho.android.data.apiclient
+
+import dev.hho.android.data.apiclient.generated.models.SyncAppliedEntry
+import dev.hho.android.data.apiclient.generated.models.SyncConflictEntry
+import dev.hho.android.data.apiclient.generated.models.SyncSkippedEntry
+import kotlinx.serialization.json.JsonElement
+
+data class PushMutation(
+ val mutationId: String,
+ val entityType: String,
+ val entityId: String,
+ val baseVersion: Long,
+ val fields: Map,
+ val op: String? = null,
+)
+
+sealed interface SyncPushOutcome {
+ data class Success(
+ val applied: List,
+ val skipped: List,
+ val conflicts: List,
+ val newWatermark: Long,
+ ) : SyncPushOutcome
+
+ data class StructuralRejection(
+ val index: Int?,
+ val mutationId: String?,
+ val detail: String,
+ ) : SyncPushOutcome
+}
+
+internal object SyncPushDetailParser {
+ const val STRUCTURAL_PREFIX = "mutations["
+
+ private val pattern = Regex("""^mutations\[(\d+)] \(mutation_id="((?:[^"\\]|\\.)*)"\): .*""", RegexOption.DOT_MATCHES_ALL)
+
+ fun parse(detail: String): SyncPushOutcome.StructuralRejection {
+ val match = pattern.matchEntire(detail)
+ val index = match?.groupValues?.get(1)?.toIntOrNull()
+ val id = match?.groupValues?.get(2)?.replace(Regex("""\\(.)"""), "$1")
+ return if (index == null || id == null) {
+ SyncPushOutcome.StructuralRejection(null, null, detail)
+ } else {
+ SyncPushOutcome.StructuralRejection(index, id, detail)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/generated/apis/AttachmentsApi.kt b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/generated/apis/AttachmentsApi.kt
new file mode 100644
index 0000000..97eba6b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/dev/hho/android/data/apiclient/generated/apis/AttachmentsApi.kt
@@ -0,0 +1,300 @@
+@file:Suppress(
+ "ArrayInDataClass",
+ "DuplicatedCode",
+ "EnumEntryName",
+ "RemoveRedundantQualifierName",
+ "RemoveRedundantCallsOfConversionMethods",
+ "REDUNDANT_CALL_OF_CONVERSION_METHOD",
+ "RedundantUnitReturnType",
+ "RemoveEmptyClassBody",
+ "UnnecessaryVariable",
+ "UnusedImport",
+ "UnnecessaryVariable",
+ "unused"
+)
+
+package dev.hho.android.data.apiclient.generated.apis
+
+import java.io.IOException
+import okhttp3.Call
+import okhttp3.HttpUrl
+
+import dev.hho.android.data.apiclient.generated.models.Attachment
+import dev.hho.android.data.apiclient.generated.models.AttachmentListResponse
+import dev.hho.android.data.apiclient.generated.models.Problem
+import dev.hho.android.data.apiclient.generated.models.UpgradeRequiredProblem
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+import dev.hho.android.data.apiclient.generated.infrastructure.ApiClient
+import dev.hho.android.data.apiclient.generated.infrastructure.ApiResponse
+import dev.hho.android.data.apiclient.generated.infrastructure.ClientException
+import dev.hho.android.data.apiclient.generated.infrastructure.ClientError
+import dev.hho.android.data.apiclient.generated.infrastructure.ServerException
+import dev.hho.android.data.apiclient.generated.infrastructure.ServerError
+import dev.hho.android.data.apiclient.generated.infrastructure.MultiValueMap
+import dev.hho.android.data.apiclient.generated.infrastructure.PartConfig
+import dev.hho.android.data.apiclient.generated.infrastructure.RequestConfig
+import dev.hho.android.data.apiclient.generated.infrastructure.RequestMethod
+import dev.hho.android.data.apiclient.generated.infrastructure.ResponseType
+import dev.hho.android.data.apiclient.generated.infrastructure.Success
+import dev.hho.android.data.apiclient.generated.infrastructure.toMultiValue
+import dev.hho.android.data.apiclient.generated.infrastructure.Serializer
+
+open class AttachmentsApi(basePath: kotlin.String = defaultBasePath, client: Call.Factory = ApiClient.defaultClient) : ApiClient(basePath, client) {
+ companion object {
+ @JvmStatic
+ val defaultBasePath: String by lazy {
+ System.getProperties().getProperty(ApiClient.BASE_URL_KEY, "/api/v1")
+ }
+ }
+
+ @Throws(IllegalStateException::class, IOException::class, UnsupportedOperationException::class, ClientException::class, ServerException::class)
+ fun deleteItemAttachment(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String? = null) : Unit {
+ val localVarResponse = deleteItemAttachmentWithHttpInfo(itemID = itemID, attachmentID = attachmentID, xHHOClientVersion = xHHOClientVersion)
+
+ return when (localVarResponse.responseType) {
+ ResponseType.Success -> Unit
+ ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.")
+ ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.")
+ ResponseType.ClientError -> {
+ val localVarError = localVarResponse as ClientError<*>
+ throw ClientException("Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse)
+ }
+ ResponseType.ServerError -> {
+ val localVarError = localVarResponse as ServerError<*>
+ throw ServerException("Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()} ${localVarError.body}", localVarError.statusCode, localVarResponse)
+ }
+ }
+ }
+
+ @Throws(IllegalStateException::class, IOException::class)
+ fun deleteItemAttachmentWithHttpInfo(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String?) : ApiResponse {
+ val localVariableConfig = deleteItemAttachmentRequestConfig(itemID = itemID, attachmentID = attachmentID, xHHOClientVersion = xHHOClientVersion)
+
+ return request(
+ localVariableConfig
+ )
+ }
+
+ fun deleteItemAttachmentRequestConfig(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String?) : RequestConfig {
+ val localVariableBody = null
+ val localVariableQuery: MultiValueMap = mutableMapOf()
+ val localVariableHeaders: MutableMap = mutableMapOf()
+ localVariableHeaders["Accept"] = "application/problem+json"
+ xHHOClientVersion?.apply { localVariableHeaders["X-HHO-Client-Version"] = this.toString() }
+
+ return RequestConfig(
+ method = RequestMethod.DELETE,
+ path = "/items/{itemID}/attachments/{attachmentID}".replace("{"+"itemID"+"}", encodeURIComponent(itemID.toString())).replace("{"+"attachmentID"+"}", encodeURIComponent(attachmentID.toString())),
+ query = localVariableQuery,
+ headers = localVariableHeaders,
+ requiresAuthentication = true,
+ body = localVariableBody
+ )
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class, UnsupportedOperationException::class, ClientException::class, ServerException::class)
+ fun downloadItemAttachment(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String? = null) : java.io.File {
+ val localVarResponse = downloadItemAttachmentWithHttpInfo(itemID = itemID, attachmentID = attachmentID, xHHOClientVersion = xHHOClientVersion)
+
+ return when (localVarResponse.responseType) {
+ ResponseType.Success -> (localVarResponse as Success<*>).data as java.io.File
+ ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.")
+ ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.")
+ ResponseType.ClientError -> {
+ val localVarError = localVarResponse as ClientError<*>
+ throw ClientException("Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse)
+ }
+ ResponseType.ServerError -> {
+ val localVarError = localVarResponse as ServerError<*>
+ throw ServerException("Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()} ${localVarError.body}", localVarError.statusCode, localVarResponse)
+ }
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class)
+ fun downloadItemAttachmentWithHttpInfo(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String?) : ApiResponse {
+ val localVariableConfig = downloadItemAttachmentRequestConfig(itemID = itemID, attachmentID = attachmentID, xHHOClientVersion = xHHOClientVersion)
+
+ return request(
+ localVariableConfig
+ )
+ }
+
+ fun downloadItemAttachmentRequestConfig(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String?) : RequestConfig {
+ val localVariableBody = null
+ val localVariableQuery: MultiValueMap = mutableMapOf()
+ val localVariableHeaders: MutableMap = mutableMapOf()
+ localVariableHeaders["Accept"] = "application/octet-stream, application/problem+json"
+ xHHOClientVersion?.apply { localVariableHeaders["X-HHO-Client-Version"] = this.toString() }
+
+ return RequestConfig(
+ method = RequestMethod.GET,
+ path = "/items/{itemID}/attachments/{attachmentID}".replace("{"+"itemID"+"}", encodeURIComponent(itemID.toString())).replace("{"+"attachmentID"+"}", encodeURIComponent(attachmentID.toString())),
+ query = localVariableQuery,
+ headers = localVariableHeaders,
+ requiresAuthentication = true,
+ body = localVariableBody
+ )
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class, UnsupportedOperationException::class, ClientException::class, ServerException::class)
+ fun downloadItemAttachmentThumbnail(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String? = null) : java.io.File {
+ val localVarResponse = downloadItemAttachmentThumbnailWithHttpInfo(itemID = itemID, attachmentID = attachmentID, xHHOClientVersion = xHHOClientVersion)
+
+ return when (localVarResponse.responseType) {
+ ResponseType.Success -> (localVarResponse as Success<*>).data as java.io.File
+ ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.")
+ ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.")
+ ResponseType.ClientError -> {
+ val localVarError = localVarResponse as ClientError<*>
+ throw ClientException("Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse)
+ }
+ ResponseType.ServerError -> {
+ val localVarError = localVarResponse as ServerError<*>
+ throw ServerException("Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()} ${localVarError.body}", localVarError.statusCode, localVarResponse)
+ }
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class)
+ fun downloadItemAttachmentThumbnailWithHttpInfo(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String?) : ApiResponse {
+ val localVariableConfig = downloadItemAttachmentThumbnailRequestConfig(itemID = itemID, attachmentID = attachmentID, xHHOClientVersion = xHHOClientVersion)
+
+ return request(
+ localVariableConfig
+ )
+ }
+
+ fun downloadItemAttachmentThumbnailRequestConfig(itemID: kotlin.String, attachmentID: kotlin.String, xHHOClientVersion: kotlin.String?) : RequestConfig {
+ val localVariableBody = null
+ val localVariableQuery: MultiValueMap = mutableMapOf()
+ val localVariableHeaders: MutableMap = mutableMapOf()
+ localVariableHeaders["Accept"] = "application/problem+json"
+ xHHOClientVersion?.apply { localVariableHeaders["X-HHO-Client-Version"] = this.toString() }
+
+ return RequestConfig(
+ method = RequestMethod.GET,
+ path = "/items/{itemID}/attachments/{attachmentID}/thumbnail".replace("{"+"itemID"+"}", encodeURIComponent(itemID.toString())).replace("{"+"attachmentID"+"}", encodeURIComponent(attachmentID.toString())),
+ query = localVariableQuery,
+ headers = localVariableHeaders,
+ requiresAuthentication = true,
+ body = localVariableBody
+ )
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class, UnsupportedOperationException::class, ClientException::class, ServerException::class)
+ fun listItemAttachments(itemID: kotlin.String, xHHOClientVersion: kotlin.String? = null) : AttachmentListResponse {
+ val localVarResponse = listItemAttachmentsWithHttpInfo(itemID = itemID, xHHOClientVersion = xHHOClientVersion)
+
+ return when (localVarResponse.responseType) {
+ ResponseType.Success -> (localVarResponse as Success<*>).data as AttachmentListResponse
+ ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.")
+ ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.")
+ ResponseType.ClientError -> {
+ val localVarError = localVarResponse as ClientError<*>
+ throw ClientException("Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse)
+ }
+ ResponseType.ServerError -> {
+ val localVarError = localVarResponse as ServerError<*>
+ throw ServerException("Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()} ${localVarError.body}", localVarError.statusCode, localVarResponse)
+ }
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class)
+ fun listItemAttachmentsWithHttpInfo(itemID: kotlin.String, xHHOClientVersion: kotlin.String?) : ApiResponse {
+ val localVariableConfig = listItemAttachmentsRequestConfig(itemID = itemID, xHHOClientVersion = xHHOClientVersion)
+
+ return request(
+ localVariableConfig
+ )
+ }
+
+ fun listItemAttachmentsRequestConfig(itemID: kotlin.String, xHHOClientVersion: kotlin.String?) : RequestConfig {
+ val localVariableBody = null
+ val localVariableQuery: MultiValueMap = mutableMapOf()
+ val localVariableHeaders: MutableMap = mutableMapOf()
+ localVariableHeaders["Accept"] = "application/json, application/problem+json"
+ xHHOClientVersion?.apply { localVariableHeaders["X-HHO-Client-Version"] = this.toString() }
+
+ return RequestConfig(
+ method = RequestMethod.GET,
+ path = "/items/{itemID}/attachments".replace("{"+"itemID"+"}", encodeURIComponent(itemID.toString())),
+ query = localVariableQuery,
+ headers = localVariableHeaders,
+ requiresAuthentication = true,
+ body = localVariableBody
+ )
+ }
+
+ enum class CategoryUploadItemAttachment(val value: kotlin.String) {
+ @SerialName(value = "image") Image("image"),
+ @SerialName(value = "manual") Manual("manual"),
+ @SerialName(value = "warranty") Warranty("warranty"),
+ @SerialName(value = "receipt") Receipt("receipt"),
+ @SerialName(value = "general") General("general");
+
+ override fun toString(): kotlin.String = "$value"
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class, UnsupportedOperationException::class, ClientException::class, ServerException::class)
+ fun uploadItemAttachment(itemID: kotlin.String, category: CategoryUploadItemAttachment, file: java.io.File, xHHOClientVersion: kotlin.String? = null) : Attachment {
+ val localVarResponse = uploadItemAttachmentWithHttpInfo(itemID = itemID, category = category, file = file, xHHOClientVersion = xHHOClientVersion)
+
+ return when (localVarResponse.responseType) {
+ ResponseType.Success -> (localVarResponse as Success<*>).data as Attachment
+ ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.")
+ ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.")
+ ResponseType.ClientError -> {
+ val localVarError = localVarResponse as ClientError<*>
+ throw ClientException("Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse)
+ }
+ ResponseType.ServerError -> {
+ val localVarError = localVarResponse as ServerError<*>
+ throw ServerException("Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()} ${localVarError.body}", localVarError.statusCode, localVarResponse)
+ }
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Throws(IllegalStateException::class, IOException::class)
+ fun uploadItemAttachmentWithHttpInfo(itemID: kotlin.String, category: CategoryUploadItemAttachment, file: java.io.File, xHHOClientVersion: kotlin.String?) : ApiResponse {
+ val localVariableConfig = uploadItemAttachmentRequestConfig(itemID = itemID, category = category, file = file, xHHOClientVersion = xHHOClientVersion)
+
+ return request