This commit is contained in:
2026-09-26 22:23:16 +02:00
commit 9d57a79c66
98 changed files with 11040 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+103
View File
@@ -0,0 +1,103 @@
#file: noinspection SpellCheckingInspection
name: CI-Android APK
env:
main_project_module: app
playstore_name: IOweU
on:
push:
branches: [main]
tags:
- "**"
pull_request:
branches: [main]
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: set up secrets
run: |
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
echo "${{ secrets.RELEASE_KEY}}" > key.asc
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
- name: Generate Dynamic Release Notes
run: |
mkdir -p tmp/whatsnew
echo "Various improvements for you to experience..." > tmp/whatsnew/whatsnew-en-US
- name: create and checkout branch
if: github.event_name == 'pull_request'
env:
BRANCH: ${{ github.head_ref }}
run: git checkout -B "$BRANCH"
- name: set up JDK
uses: actions/setup-java@v6
with:
java-version: 21
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Lint and unit tests
run: ./gradlew lintDebug testDebugUnitTest
- name: Build APK
run: |
VERSION_CODE=$(git rev-list --count HEAD)
VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
if [ $VERSION_DEV -gt 0 ]; then
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew bundleSignedRelease assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew bundleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK
uses: actions/upload-artifact@v7
with:
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
compression-level: 0
archive: false
- name: Release
uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
app/build/outputs/apk/signedRelease/app-signedRelease.apk
prerelease: |
if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+\.[0-9]+[a-z]+$ ]]; then
echo "false"
else
echo "true"
fi
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
if: startsWith(github.ref, 'refs/tags/')
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: net.helcel.owu
releaseFiles: app/build/outputs/bundle/signedRelease/app-signedRelease.aab
tracks: "alpha" # ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'alpha' }}
status: completed
whatsNewDirectory: tmp/whatsnew
+25
View File
@@ -0,0 +1,25 @@
*.iml
.idea/
node_modules/
temp/
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
.yarn
app/build/
app/debug/
app/release/
captures/
local.properties
keystore.properties
key.jks
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is 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. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
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.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
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 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. Use with the GNU Affero General Public License.
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 Affero 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 special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU 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 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 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 General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) 2026 Helcel
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) 2026 Helcel
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
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 GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+86
View File
@@ -0,0 +1,86 @@
# Privacy Policy for iOweU
**App:** iOweU (`net.helcel.owu`)
**Developer:** Helcel
**Effective date:** 25 September 2026
## Data collection
iOweU does not collect, transmit, or share any personal or sensitive user data.
The app has no internet access, contains no analytics, advertising, or
crash-reporting libraries, and requires no account.
It asks for three permissions, each only at the moment it is needed:
- **Location**, when an OwU is tied to a place. The reading is compared with
that place on your device and then discarded. It is not stored, not written
into the OwU, and not sent anywhere.
- **Camera**, while you scan another person's identity QR code. No image is
stored.
- **Nearby devices (Bluetooth)**, while the app is open, to find and trade with
a phone next to you. Bluetooth is never used to work out where you are.
Two phones find each other by advertising a short code (a hash of the public
key, not the key itself) that any phone in range can hear. The exchange
itself goes over a connection to the phone you picked, so only that phone
receives what you trade. It is not encrypted, so treat an OwU's contents as
readable by whoever you are trading with, and by anyone who can listen to
that connection. Nothing else on your device is ever put on the air.
## Data stored on your device
Everything you enter in the app - your OwUs, your address book of contacts'
public keys and the names you give them, and your display preferences - is
saved only in the app's private storage on your device. This data is never
sent to us or to any third party. An OwU is only ever sent to another person
when you choose to share it.
Your signing key is generated on your device and stored in the app's private
storage, sealed with a key held in the Android Keystore - a copy
of the file is useless on any other device.
You can export everything, including that key, to a backup file you choose the
location of (Settings › Backup). That file is encrypted with a passphrase you
pick, and nobody - including us - can open it without that passphrase. It is
also the one thing that can make another phone _be_ you, so keep it as you
would keep a key to your home.
If Android's system backup is enabled on your device, your operating system may
include this data in your device backup. That is handled by Android under your
device vendor's privacy policy, not by iOweU.
## Data sharing
None. No data leaves your device, so there is nothing to share, sell, or
disclose to third parties.
## Data retention and deletion
Your data remains on your device until you delete it. Clearing the app's data
in Android Settings, or uninstalling iOweU, permanently removes everything the
app has stored.
## Children
iOweU is suitable for all ages and collects no data from any user, including
children.
## Security
Because no data is transmitted or stored on any server, there is no remote data
to secure. On-device data is protected by Android's app sandbox.
## Open source
iOweU is released under the GNU General Public License, version 3 or later. The full source is
available at https://github.com/helcel-net/iOweU, so these statements can be
independently verified.
## Changes
Any change affecting privacy will be published in this document before or with
the release that introduces it.
## Contact
Issues: https://github.com/helcel-net/iOweU/issues
+85
View File
@@ -0,0 +1,85 @@
<!--suppress ALL -->
<div align="center">
<h1>OwU</h1>
<p>Owe you - keep track of who owes whom</p>
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
<br>
<a href="https://github.com/helcel-net/iOweU/actions/workflows/build.yml">
<img src="https://github.com/helcel-net/iOweU/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
</a>
</div>
## ⭐ Features
- Signed IOUs: each OwU is a chain of custody, signed with a P-256 key held by the app and sealed by the phone's keystore
- Back it all up - identity, OwUs, templates, contacts - to one passphrase-encrypted file, and restore it on your next phone
- Write, trade (each side puts in an OwU or nothing; a swap is atomic) and redeem OwUs in person, over Bluetooth with nothing to pair. No server.
- Being asked to trade, and being asked to redeem, reach the other person wherever they are in the app - and a "no" is sent back rather than left as silence
- Tie an OwU to a place or a time window
- Identities exchanged by QR code (the only thing codes are used for)
- Small & Fast
- 100% Free and Open Source software, with no proprietary dependencies
See [docs/SPEC.md](docs/SPEC.md) for the protocol.
## 🤝 How it works
Every OwU is a promise from one person (the debtor) to another (the holder), signed with a key that never leaves the debtor's phone. Nothing happens on a server; OwUs move only when two people meet.
1. **Your identity** is a key pair, and a name you choose for yourself that travels with it. You meet somebody by scanning the code on their profile, which stores their key under whatever you decide to call them - and that key is what lets an OwU they owe be recognised as theirs even when it reaches you through somebody else. OwUs owed by people you have not met say so.
2. **The table** is how anything moves. Tap the trade arrow beside somebody in your contacts and they are asked to the table wherever they are in the app; then each of you puts one OwU on your side of it, or nothing. You both see what is there and what you would each walk away with; it happens when you have both confirmed. A gift is your OwU against their nothing; a swap is an OwU on each side, and it is atomic - neither side can end up short. Nothing is paired: the phones find each other by advertisement and open a connection that lasts as long as the trade. You can only trade with somebody you have named, which is the point of naming them.
3. **`+`** writes a promise you are _ready_ to make, and keeps it. Nothing is signed and nobody is owed anything yet. Each time you put it on a table, a fresh OwU is minted and signed from it - so one "1 Beer" serves every round you ever buy. Delete it when you are done with it.
4. **Redeeming** is the same table, pointed the other way, and it needs no screen: press Redeem on an OwU and the person who owes it gets a prompt, wherever they are. They accept, or they say not now and you are told so. If they are not around the ask waits and goes out by itself when they turn up. OwUs can be tied to a place or a time window, and that is shown in red when it has passed or you are elsewhere - never enforced, because only the person who wrote the promise can say whether it still counts.
**Forging an OwU is not possible** without the private key of the person who owes it, and keys never leave the phone that made them. **Copying one cannot be prevented** - no offline system can stop someone signing the same OwU over to two people - so OwU catches it instead: the moment an OwU is put on the table it is checked against the copy you already hold, and a history that contradicts yours, or one that has already moved on, cannot be accepted. Failing that, the debtor is the final arbiter: whichever copy they honour wins and the other is dead.
## 📳 Installation
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
<a href="https://github.com/helcel-net/iOweU/releases/latest">
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a>
</div>
## ⚙️ Permissions
- **Location** - only when you redeem an OwU that is tied to a place, to check you are there. Never stored or sent.
- **Camera** - only while scanning a contact's QR code.
- **Nearby devices (Bluetooth)** - while the app is open, to find and talk to other phones running OwU next to you, so that a trade or a redeem can reach you on any screen. It stops the moment the app is not the one in use.
## 📝 Contribute
OwU is a user-driven project. We welcome any contribution, big or small.
- **🖥️ Development:** Fix bugs, implement features, or research issues. Open a PR for review.
- **🍥 Design:** Improve interfaces, including accessibility and usability.
- **📂 Issue Reporting:** Report bugs and edge cases with relevant info.
- **🌍 Localization:** Translate if it doesn't support your language.
## ✏️ Acknowledgements
Thanks to all contributors, the developers of our dependencies, and our users.
## 📝 License
GNU GPL v3 or later. The full text is in [LICENSE](LICENSE).
```
Copyright (C) 2026 Helcel
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
```
+1
View File
@@ -0,0 +1 @@
/build
+120
View File
@@ -0,0 +1,120 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
}
android {
namespace 'net.helcel.owu'
compileSdk = 37
defaultConfig {
// Two names for one app: what it calls itself on its own screens, and
// what it is called out in the world - the launcher, the store listing
// and the About page.
buildConfigField("String", "PUBLIC_NAME", "\"iOweU\"")
manifestPlaceholders["PUBLIC_NAME"] = "iOweU"
applicationId 'net.helcel.owu'
minSdk = 28
targetSdk = 37
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "0.1"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
}
signingConfigs {
register("release") {
try {
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
} catch (FileNotFoundException e) {
println("File not found: ${e.message}")
}
}
}
buildTypes {
debug {
debuggable true
applicationIdSuffix ".debug"
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
signedRelease {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
encoding 'utf-8'
}
buildFeatures {
compose true
buildConfig true
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
androidResources {
// The app ships English strings only; without this the libraries drag
// in some eighty locales of their own.
localeFilters += ["en"]
}
testOptions {
// IouStore logs through android.util.Log on unreadable files; the JVM has no Log.
unitTests.returnDefaultValues = true
}
lint {
disable 'UsingMaterialAndMaterial3Libraries'
// Strings looked up inside click handlers, where stringResource() is
// not available; the context captured at composition is the right one.
disable 'LocalContextGetResourceValueCall'
}
}
dependencies {
implementation 'androidx.compose.material3:material3:1.4.0'
implementation "androidx.compose.material:material:1.12.1"
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
implementation 'androidx.navigation:navigation-compose:2.10.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
// QR codes for exchanging identities. Both Apache-2.0, no Play Services.
implementation 'com.google.zxing:core:3.5.4'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:2.4.20'
implementation 'androidx.compose.ui:ui'
implementation "androidx.activity:activity-ktx:1.13.0"
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation platform('androidx.compose:compose-bom:2026.09.00')
debugImplementation 'androidx.compose.ui:ui-tooling:1.12.1'
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Reading the geoloc gate on a place-bound OwU. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Scanning a contact's identity QR code. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Talking to a nearby device. Location is never derived from scans. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-feature android:name="android.hardware.location" android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Owu"
android:label="${PUBLIC_NAME}"
android:supportsRtl="true">
<activity
android:name=".activity.MainScreen"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
The scanner comes from a library whose own manifest pins it to
sensorLandscape, so it turned sideways the moment it opened.
"behind" means the orientation of the screen it was opened from,
which is the only sensible answer: the app is held one way and the
camera should be held the same way.
-->
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="behind"
tools:ignore="DiscouragedApi"
tools:replace="android:screenOrientation" />
</application>
</manifest>
@@ -0,0 +1,238 @@
package net.helcel.owu.activity
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.AlertDialog
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.owu.R
import net.helcel.owu.crypto.Identity
import net.helcel.owu.crypto.Keys
import net.helcel.owu.helper.toast
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.store.Backup
import net.helcel.owu.store.BackupException
import net.helcel.owu.store.Repo
import java.time.LocalDate
import java.util.Base64
/**
* Settings' backup corner: write everything this phone is to a file, and read
* one back - on this phone or the next one.
*/
@Composable
fun BackupSection() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var passphraseFor by remember { mutableStateOf<Uri?>(null) } // writing
var restoreFrom by remember { mutableStateOf<Uri?>(null) } // reading
var takeOver by remember { mutableStateOf<Backup.Contents?>(null) }
val create = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
passphraseFor = uri
}
val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
restoreFrom = uri
}
fun write(uri: Uri, passphrase: String) {
scope.launch {
val trouble = withContext(Dispatchers.IO) {
runCatching {
val (priv, pub) = Repo.signer.export()
val contents = Backup.Contents(
privateKey = Base64.getEncoder().encodeToString(priv),
publicKey = Base64.getEncoder().encodeToString(pub),
name = Repo.myName(context),
ious = Repo.store.ious.value.values.toList(),
templates = Repo.store.templates.value.values.toList(),
contacts = Repo.store.contacts.value,
)
val bytes = Backup.seal(contents, passphrase.toCharArray())
context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(bytes) }
?: error("could not write there")
}.exceptionOrNull()
}
context.toast(
if (trouble == null) context.getString(R.string.backup_written)
else trouble.message ?: context.getString(R.string.backup_failed)
)
}
}
fun read(uri: Uri, passphrase: String) {
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("could not read that file")
Backup.open(bytes, passphrase.toCharArray())
}
}
result.fold(
onSuccess = { contents ->
// OwUs, templates and names come back whatever happens;
// the identity is a separate question, since taking it on
// means giving up the one this phone has.
restore(contents)
val mine = Keys.decode(contents.publicKey)?.let { Base64.getEncoder().encodeToString(it.encoded) }
if (mine == Repo.me) context.toast(context.getString(R.string.backup_restored))
else takeOver = contents
},
onFailure = {
context.toast(
(it as? BackupException)?.message ?: context.getString(R.string.backup_failed)
)
},
)
}
}
// --- dialogs -----------------------------------------------------------
passphraseFor?.let { uri ->
PassphraseDialog(
title = stringResource(R.string.backup_export),
hint = stringResource(R.string.backup_passphrase_hint),
confirmTwice = true,
onDismiss = { passphraseFor = null },
) { passphrase ->
passphraseFor = null
write(uri, passphrase)
}
}
restoreFrom?.let { uri ->
PassphraseDialog(
title = stringResource(R.string.backup_restore),
hint = stringResource(R.string.backup_passphrase_ask),
confirmTwice = false,
onDismiss = { restoreFrom = null },
) { passphrase ->
restoreFrom = null
read(uri, passphrase)
}
}
takeOver?.let { contents ->
AlertDialog(
onDismissRequest = { takeOver = null },
title = { Text(stringResource(R.string.backup_identity_title)) },
text = { Text(stringResource(R.string.backup_identity_text, Keys.fingerprint(contents.publicKey))) },
confirmButton = {
TextButton(onClick = {
takeOver = null
runCatching {
PeerManager.stop()
Repo.adopt(
Identity.replace(
context,
Base64.getDecoder().decode(contents.privateKey),
Base64.getDecoder().decode(contents.publicKey),
)
)
Repo.setMyName(context, contents.name)
}.fold(
onSuccess = { context.toast(context.getString(R.string.backup_identity_taken)) },
onFailure = { context.toast(it.message ?: context.getString(R.string.backup_failed)) },
)
}) { Text(stringResource(R.string.backup_identity_take)) }
},
dismissButton = {
TextButton(onClick = { takeOver = null }) { Text(stringResource(R.string.backup_identity_keep)) }
},
)
}
// --- the two buttons, side by side -------------------------------------
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedButton(
onClick = { create.launch("owu-backup-${LocalDate.now()}.json") },
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.backup_export)) }
Spacer(Modifier.size(12.dp))
OutlinedButton(
onClick = { pick.launch(arrayOf("application/json", "application/octet-stream", "*/*")) },
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.backup_restore)) }
}
}
/** Puts back everything but the identity, through the ordinary merge rules. */
private fun restore(contents: Backup.Contents) {
contents.ious.forEach { Repo.store.merge(it) }
contents.templates.forEach { Repo.store.putTemplate(it) }
contents.contacts.forEach { Repo.store.putContact(it) }
}
/** Asks for a passphrase, twice when one is being chosen rather than recalled. */
@Composable
private fun PassphraseDialog(
title: String,
hint: String,
confirmTwice: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var first by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
val ready = first.length >= 8 && (!confirmTwice || first == again)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
Text(
hint, style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
)
OutlinedTextField(
value = first, onValueChange = { first = it },
label = { Text(stringResource(R.string.backup_passphrase)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),
modifier = Modifier.padding(top = 12.dp),
)
if (confirmTwice) {
OutlinedTextField(
value = again, onValueChange = { again = it },
label = { Text(stringResource(R.string.backup_passphrase_again)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),
modifier = Modifier.padding(top = 8.dp),
)
}
}
},
confirmButton = {
TextButton(enabled = ready, onClick = { onConfirm(first) }) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@@ -0,0 +1,71 @@
package net.helcel.owu.activity
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothDisabled
import androidx.compose.material.icons.automirrored.filled.BluetoothSearching
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import net.helcel.owu.R
import net.helcel.owu.ble.BlePermissions
import net.helcel.owu.helper.toast
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerManager.Radio
/** Top-bar Bluetooth state. Tap: request permission, or ask the system to enable. */
@Composable
fun BluetoothAction() {
val context = LocalContext.current
val radio by PeerManager.radio.collectAsState()
val onAir by PeerManager.state.collectAsState()
// Result unused: the adapter broadcast starts us.
val enable = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
val permissions = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
PeerManager.resume(context)
if (granted.values.all { it } && PeerManager.radio.value == Radio.OFF) {
enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
}
val live = radio == Radio.ON && onAir.active
val (icon, label) = when {
live -> Icons.Default.Bluetooth to R.string.bluetooth_on
radio == Radio.ON || radio == Radio.TURNING_ON ->
Icons.AutoMirrored.Filled.BluetoothSearching to R.string.bluetooth_starting
else -> Icons.Default.BluetoothDisabled to R.string.bluetooth_off
}
IconButton(onClick = {
when (radio) {
Radio.UNSUPPORTED -> context.toast(context.getString(R.string.bluetooth_unsupported))
Radio.NO_PERMISSION -> permissions.launch(BlePermissions.required().toTypedArray())
Radio.OFF -> runCatching { enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
// Fallback when the enable prompt is missing.
.onFailure { context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }
Radio.TURNING_ON -> Unit
Radio.ON -> {
if (!live) PeerManager.resume(context)
context.toast(onAir.error ?: context.getString(label))
}
}
}) {
Icon(
icon,
contentDescription = stringResource(label),
tint = LocalContentColor.current.copy(alpha = if (live) 1f else 0.6f),
)
}
}
@@ -0,0 +1,365 @@
package net.helcel.owu.activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.crypto.Keys
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.helper.clipboardText
import net.helcel.owu.helper.copyToClipboard
import net.helcel.owu.helper.toast
import net.helcel.owu.store.Contact
import net.helcel.owu.store.Repo
@Composable
fun IdentityScreen(nav: NavHostController) {
val context = LocalContext.current
val contacts by Repo.store.contacts.collectAsState()
val onAir by PeerManager.state.collectAsState()
var name by remember { mutableStateOf(Repo.myName(context)) }
val card = remember(name) { IdentityCard(name = name.trim(), key = Repo.me).encode() }
// A scanned or pasted card is confirmed (and named) before it is kept.
var pending by remember { mutableStateOf<IdentityCard?>(null) }
var showManual by remember { mutableStateOf(false) }
// A name is yours to choose and yours to change; the pencil on the row
// opens it, since the row itself now goes to their table.
var renaming by remember { mutableStateOf<Contact?>(null) }
// Passing one on: the same card this screen shows for yourself, for them.
var showing by remember { mutableStateOf<Contact?>(null) }
fun offer(text: String?) {
val c = text?.let { IdentityCard.decode(it) }
?: text?.trim()?.takeIf { Keys.decode(it) != null }?.let { IdentityCard(name = "", key = it) }
if (c == null) context.toast(context.getString(R.string.contact_unreadable))
else if (c.key == Repo.me) context.toast(context.getString(R.string.contact_is_you))
else pending = c
}
val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> result.contents?.let { offer(it) } }
fun scan() = scanner.launch(ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt(context.getString(R.string.scan_prompt))
setBeepEnabled(false)
// Held the way the app is held. Unlocked, it follows the sensor and
// turns sideways while you are lining a code up.
setOrientationLocked(true)
})
pending?.let { c ->
var contactName by remember(c) { mutableStateOf(c.name) }
AlertDialog(
onDismissRequest = { pending = null },
title = { Text(stringResource(R.string.contact_add)) },
text = {
Column {
Text(Keys.fingerprint(c.key), fontFamily = FontFamily.Monospace)
OutlinedTextField(
value = contactName, onValueChange = { contactName = it },
label = { Text(stringResource(R.string.name)) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(enabled = contactName.isNotBlank(), onClick = {
Repo.store.putContact(Contact(c.key, contactName.trim()))
pending = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = { pending = null }) { Text(stringResource(R.string.cancel)) } },
)
}
renaming?.let { c ->
var name by remember(c) { mutableStateOf(c.name) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text(stringResource(R.string.contact_rename)) },
text = {
Column {
Text(Keys.fingerprint(c.publicKey), fontFamily = FontFamily.Monospace)
OutlinedTextField(
value = name, onValueChange = { name = it },
label = { Text(stringResource(R.string.name)) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = {
Repo.store.putContact(Contact(c.publicKey, name.trim()))
renaming = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = { renaming = null }) { Text(stringResource(R.string.cancel)) } },
)
}
// A contact's card for somebody else to scan, the same shape as your own.
// The name travels as a suggestion; the key is what checks their signatures.
//
// A plain Dialog, not an AlertDialog: M2's lays its text slot out by first
// and last text baseline, and a QR code has neither, so a slot starting
// with one measures to a negative height and throws.
showing?.let { c ->
Dialog(onDismissRequest = { showing = null }) {
Card(shape = MaterialTheme.shapes.medium, elevation = 8.dp) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp),
) {
Text(c.name, style = MaterialTheme.typography.h6)
QrCode(
remember(c) { IdentityCard(name = c.name, key = c.publicKey).encode() },
modifier = Modifier.padding(top = 16.dp).size(240.dp),
)
Text(
Keys.fingerprint(c.publicKey),
style = MaterialTheme.typography.caption,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 8.dp),
)
TextButton(
onClick = { showing = null },
modifier = Modifier.align(Alignment.End).padding(top = 8.dp),
) { Text(stringResource(R.string.ok)) }
}
}
}
}
if (showManual) {
var key by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showManual = false },
title = { Text(stringResource(R.string.contact_add_manually)) },
text = {
OutlinedTextField(
value = key, onValueChange = { key = it },
label = { Text(stringResource(R.string.field_key)) },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = { showManual = false; offer(key) }) { Text(stringResource(R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showManual = false; offer(context.clipboardText()) }) {
Text(stringResource(R.string.contact_from_clipboard))
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.identity_title)) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OutlinedTextField(
value = name,
onValueChange = { name = it; Repo.setMyName(context, it.trim()) },
label = { Text(stringResource(R.string.field_your_name)) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
QrCode(card, modifier = Modifier.padding(top = 16.dp).size(240.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
Text(
Keys.fingerprint(Repo.me),
style = MaterialTheme.typography.h6,
fontFamily = FontFamily.Monospace,
)
IconButton(onClick = {
context.copyToClipboard("iou key", Repo.me)
context.toast(context.getString(R.string.copied))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy_key))
}
}
// The two ways to become a contact, at the head of the list they
// join rather than below however many people you already know.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 4.dp),
) {
Text(
stringResource(R.string.contacts),
style = MaterialTheme.typography.h6,
modifier = Modifier.weight(1f),
)
// A camera and a plus. A QR glyph here was indistinguishable
// from the one on every row below, which *shows* a code rather
// than reads one; a key says "key" only to us.
IconButton(onClick = { scan() }) {
Icon(Icons.Default.PhotoCamera, contentDescription = stringResource(R.string.contact_scan))
}
IconButton(onClick = { showManual = true }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.contact_add_manually))
}
}
Divider()
if (contacts.isEmpty()) {
Text(
stringResource(R.string.no_contacts),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
}
// Who is on the air, read once: the rows say it and the sort uses
// it, and two readings of the same thing drift apart.
val here = onAir.peers.mapNotNull { (hex, session) ->
key(hex) {
val ss by session.state.collectAsState()
hex.takeIf { ss.phase != PeerSession.Phase.GONE }
}
}.toSet()
// Here now first, then the rest, alphabetical within each, so a
// name does not wander far when somebody arrives or leaves.
contacts.sortedWith(
compareByDescending<Contact> { Ble.beaconHex(it.publicKey) in here }
.thenBy { it.name.lowercase() }
).forEach { c ->
ContactRow(
contact = c,
here = Ble.beaconHex(c.publicKey) in here,
onTrade = { nav.navigate("peer/${Ble.beaconHex(c.publicKey)}") },
onShare = { showing = c },
onRename = { renaming = c },
onDelete = { Repo.store.removeContact(c.publicKey) },
)
Divider()
}
}
}
}
/**
* One person you have named: name, key, whether they are in earshot, and the
* button that opens the table with them.
*
* The only door to a trade: you cannot hand a promise to somebody you have not
* named. Redeeming is not a trade and does not come through here - what
* somebody owes you is asked for from the OwU itself, on any screen.
*/
@Composable
private fun ContactRow(
contact: Contact,
here: Boolean,
onTrade: () -> Unit,
onShare: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onRename)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
// A dot beside the name rather than a line of prose under it:
// being here is a property of the person, and it reads at a
// glance down a list where three lines apiece did not. Lit in the
// theme's own colour when they are on the air, all but out when
// they are not - and it keeps its words for the screen reader.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(contact.name, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
Icon(
Icons.Default.Circle,
contentDescription = stringResource(if (here) R.string.contact_here else R.string.contact_away),
tint = if (here) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.2f),
modifier = Modifier.padding(start = 8.dp).size(10.dp),
)
}
Text(
Keys.fingerprint(contact.publicKey),
style = MaterialTheme.typography.caption, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
// Greyed and dead until they are actually on the air: a table needs
// both of you, and there is nothing useful to open without them.
IconButton(onClick = onTrade, enabled = here) {
Icon(
Icons.Default.SwapHoriz,
contentDescription = stringResource(R.string.contact_trade),
tint = if (here) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.3f),
)
}
IconButton(onClick = onShare) {
Icon(Icons.Default.QrCode2, contentDescription = stringResource(R.string.contact_share))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
}
}
}
@@ -0,0 +1,315 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.helper.Gates
import net.helcel.owu.helper.formatTime
import net.helcel.owu.ledger.Block
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.TimeGate
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
/** One OwU: what it is, where it stands, its history - and, when somebody
* else owes it, the button that asks them to redeem it. */
@Composable
fun IouDetailScreen(nav: NavHostController, id: String) {
val context = LocalContext.current
val ious by Repo.store.ious.collectAsState()
// Subscribed, never read: Repo.nameOf() is a plain lookup, so without this
// nothing here would notice a contact being renamed.
@Suppress("UNUSED_VARIABLE") val contacts by Repo.store.contacts.collectAsState()
val iou = ious[id]
if (iou == null) {
// Gone. Leaving is an effect, not part of drawing: popping from
// composition ran again next frame and took the screen underneath too,
// leaving an empty NavHost and a blank screen.
LaunchedEffect(id) { nav.up() }
return
}
val verdict = remember(iou) { Verifier.verify(iou) }
val state = verdict.stateOrNull
val me = Repo.me
var showDelete by remember { mutableStateOf(false) }
// --- dialogs -----------------------------------------------------------
if (showDelete) {
AlertDialog(
onDismissRequest = { showDelete = false },
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.delete_iou_confirm)) },
confirmButton = {
// Removing is enough: the screen sees it gone and leaves.
// Popping here too was the second pop that emptied the stack.
TextButton(onClick = { Repo.store.remove(iou.id); showDelete = false }) {
Text(stringResource(R.string.delete))
}
},
dismissButton = { TextButton(onClick = { showDelete = false }) { Text(stringResource(R.string.cancel)) } },
)
}
// --- screen ------------------------------------------------------------
Scaffold(
topBar = {
TopAppBar(
title = { Text(iou.metadata.title) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { showDelete = true }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
if (state == null) {
val v = verdict as Verdict.Invalid
Text(stringResource(R.string.invalid_chain, v.sequence, v.reason), color = MaterialTheme.colors.error)
return@Column
}
// What was written with it, before the bookkeeping: the part read.
iou.metadata.description?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.body1,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
Field(stringResource(R.string.field_status), statusLabel(state.status))
Field(stringResource(R.string.role_debtor), Repo.nameOf(state.debtor), verified = state.debtor)
Field(stringResource(R.string.role_holder), Repo.nameOf(state.holder))
if (iou.metadata.hasWindow) {
Field(
stringResource(R.string.field_window),
listOfNotNull(
iou.metadata.notBefore?.let { stringResource(R.string.window_from, formatTime(it)) },
iou.metadata.notAfter?.let { stringResource(R.string.window_until, formatTime(it)) },
).joinToString(" "),
)
}
iou.metadata.geoloc?.let { g ->
Field(
stringResource(R.string.field_geoloc),
(g.label?.let { "$it · " } ?: "") + "%.5f, %.5f · %d m".format(g.latitude, g.longitude, g.radiusM),
)
}
// Redeeming is handing it back to whoever wrote it. Your own
// promise closes itself on coming home, so this is only ever
// somebody else's, in your hands.
if (state.status == Status.ACTIVE && state.holder == me && state.debtor != me) {
// Greyed when late, early or elsewhere, and still pressable:
// none of that decides anything. Only the one who wrote it can.
var away by remember(iou.id) { mutableStateOf(false) }
LaunchedEffect(iou.id) { away = Gates.outOfPlace(context, iou) }
val off = iou.metadata.timeGate() != TimeGate.Open || away
// An ask already out: show it rather than a button that would
// only ask again. Silence is the one thing not to do.
val onAir by PeerManager.state.collectAsState()
val session = onAir.peers[Ble.beaconHex(state.debtor)]
val ask = askOutstanding(session, iou.id)
// Asked for, kept until it is on their table: until then they
// are still to be found. Its own state, and it must look like
// one, or the button appears to have done nothing.
val parked = TradeIntent.offer?.takeIf { it.iouId == iou.id } != null
if (ask != null || parked) {
AskStatus(
who = Repo.nameOf(state.debtor),
looking = ask == null,
undelivered = ask?.undelivered != null,
onStop = {
TradeIntent.offer = null
session?.put()
},
)
} else Button(
// Earshot is not this button's business: the ask is written
// down and [PendingAsk] finds them, connects and sends it.
// Nowhere to go, nothing to arrange.
onClick = { TradeIntent.offer = TradeIntent.Offer(state.debtor, iou.id) },
colors = if (!off) ButtonDefaults.buttonColors() else ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.38f),
),
elevation = if (off) ButtonDefaults.elevation(0.dp, 0.dp, 0.dp) else ButtonDefaults.elevation(),
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
) { Text(stringResource(R.string.action_redeem)) }
}
Text(
stringResource(R.string.history),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 24.dp, bottom = 4.dp),
)
Divider()
// Handing an OwU back to its author and the author closing it are
// one thing happening - redeeming - so the pair is shown once.
iou.chain.filterIndexed { i, block ->
!(block is Block.Transfer && block.transferee == debtorOf(iou) && iou.chain.getOrNull(i + 1) is Block.Redeemed)
}.forEach { block ->
HistoryRow(iou, block)
Divider()
}
Text(
iou.id,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
@Composable
private fun Field(label: String, value: String, verified: String? = null) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
label,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(width = 96.dp, height = 20.dp),
)
Text(value, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Medium)
verified?.let { KnownMark(it) }
}
}
/** The one who made the promise, and so the only one who can close it. */
private fun debtorOf(iou: Iou): String? = (iou.chain.firstOrNull() as? Block.Issue)?.debtor
@Composable
private fun HistoryRow(iou: Iou, block: Block) {
// A promise is always written to oneself, so there is no "to" worth
// saying; and giving it back to its author is redeeming it, which is the
// name this and the closing that follows it go by.
val debtor = debtorOf(iou)
val handedBack = iou.chain.getOrNull(block.sequence - 1) as? Block.Transfer
val line = when (block) {
is Block.Issue -> stringResource(R.string.hist_issued, Repo.nameOf(block.debtor))
// A swap says so; a plain hand-over reads by where it went.
is Block.Transfer -> when {
block.agreement != null ->
stringResource(R.string.hist_exchanged, Repo.nameOf(block.transferor), Repo.nameOf(block.transferee))
block.transferee == debtor -> stringResource(R.string.hist_redeemed, Repo.nameOf(block.transferor))
else ->
stringResource(R.string.hist_transferred, Repo.nameOf(block.transferor), Repo.nameOf(block.transferee))
}
// Normally this closes a hand-back and takes its name; a chain that
// reaches this state another way still gets a line of its own.
is Block.Redeemed ->
if (handedBack != null && handedBack.transferee == debtor)
stringResource(R.string.hist_redeemed, Repo.nameOf(handedBack.transferor))
else stringResource(R.string.hist_closed, Repo.nameOf(block.debtor))
}
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(line, style = MaterialTheme.typography.body1)
Text(
"#${block.sequence} · ${formatTime(block.timestamp)}",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
}
/**
* The state of an ask already sent to this OwU's debtor, or null when there
* is none: my side of their table holds exactly this OwU, and I have said
* yes, so the only thing left is their answer.
*/
@Composable
private fun askOutstanding(session: PeerSession?, iouId: String): PeerSession.State? {
if (session == null) return null
val ss by session.state.collectAsState()
val mine = ss.table.mine
return ss.takeIf { mine.size == 1 && mine[0].id == iouId && ss.table.accepted }
}
/** Looking for them, waiting on them, or unable to reach them - and a way to stop any of it. */
@Composable
private fun AskStatus(who: String, looking: Boolean, undelivered: Boolean, onStop: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (looking) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
}
Text(
stringResource(
when {
looking -> R.string.redeem_looking
undelivered -> R.string.redeem_unreachable
else -> R.string.redeem_waiting
},
who,
),
style = MaterialTheme.typography.body1,
color = if (undelivered && !looking) MaterialTheme.colors.error else MaterialTheme.colors.onSurface,
)
}
TextButton(onClick = onStop, modifier = Modifier.padding(top = 4.dp)) {
Text(stringResource(R.string.redeem_stop))
}
}
}
@@ -0,0 +1,226 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Card
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.BuildConfig
import net.helcel.owu.R
import net.helcel.owu.ledger.Block
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouState
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Template
import net.helcel.owu.store.Repo
@Composable
fun IouListScreen(nav: NavHostController) {
val ious by Repo.store.ious.collectAsState()
// Subscribed, never read: names come from contacts through Repo.nameOf(),
// which is a plain lookup, so without this nothing here would notice a
// contact being added or renamed.
@Suppress("UNUSED_VARIABLE") val contacts by Repo.store.contacts.collectAsState()
val me = Repo.me
val verified = remember(ious) {
ious.values.mapNotNull { iou -> Verifier.verify(iou).stateOrNull?.let { iou to it } }
.sortedByDescending { it.first.head.timestamp }
}
// OwUs that are over live under Settings; this screen is what is live.
// Anything in your hands is held - including a promise of your own that
// is on its way somewhere, which says "not given yet" for itself. One
// somebody else holds and you wrote is a debt.
val open = verified.filterNot { it.second.status == Status.REDEEMED }
val held = open.filter { it.second.holder == me }
// OwUs you can do nothing about from here - what you owe, what is spent,
// and the promises you keep ready - are all under Settings.
val other = open.filterNot { it.second.holder == me || it.second.debtor == me }
val titleOther = stringResource(R.string.section_other)
Scaffold(
topBar = {
TopAppBar(
// The outward name, the same one on the launcher: this bar is
// the first thing anybody sees of the app.
title = { Text(BuildConfig.PUBLIC_NAME) },
actions = {
// No trade icon here any more. Trading is something you do
// with a person, not with the app, so it starts from the
// person - tap them under your own profile and their table
// opens. One fewer thing in the bar, and one fewer screen
// between wanting to trade and trading.
BluetoothAction()
IconButton(onClick = { nav.navigate("identity") }) {
Icon(Icons.Default.Person, contentDescription = stringResource(R.string.identity_title))
}
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings))
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.navigate("issue") }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_new))
}
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (open.isEmpty()) {
Text(
stringResource(R.string.list_empty),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.Center).padding(32.dp),
)
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
// What you hold needs no heading: it is what this screen is.
section(null, held, nav)
section(titleOther, other, nav)
item { Spacer(Modifier.size(80.dp)) } // room under the FAB
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.section(
title: String?,
rows: List<Pair<Iou, IouState>>,
nav: NavHostController,
) {
if (rows.isEmpty()) return
if (title != null) item { SectionHeader(title) }
items(rows.size) { i ->
val (iou, state) = rows[i]
IouRow(iou, state) { nav.navigate("iou/${iou.id}") }
}
}
@Composable
fun SectionHeader(text: String) {
Text(
text,
style = MaterialTheme.typography.subtitle2,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
/**
* "from Alice" when you hold it, "to Bob" when you owe it, and for an OwU
* sitting with the person who made it, what that means: not given yet, back
* with them, or spent.
*/
@Composable
fun counterpartyLine(state: IouState): String = when {
state.debtor == state.holder -> when {
state.status == Status.REDEEMED -> stringResource(R.string.closed_by_x, Repo.nameOf(state.debtor))
state.debtor == Repo.me -> stringResource(R.string.yours_to_give)
else -> stringResource(R.string.back_with_x, Repo.nameOf(state.debtor))
}
state.debtor == Repo.me -> stringResource(R.string.to_x, Repo.nameOf(state.holder))
state.holder == Repo.me -> stringResource(R.string.from_x, Repo.nameOf(state.debtor))
else -> "${Repo.nameOf(state.debtor)} → ${Repo.nameOf(state.holder)}"
}
@Composable
fun IouRow(iou: Iou, state: IouState, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
elevation = 2.dp,
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(iou.metadata.title, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
Text(
counterpartyLine(state),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
MetaGates(iou.metadata)
}
// No status here: every row on a list is the same status as the
// list it is on, so the label only ever repeated the screen.
}
}
}
@Composable
fun TemplateRow(template: Template, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
elevation = 2.dp,
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
template.metadata.title.ifBlank { stringResource(R.string.untitled) },
style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.template_ready),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
}
// How many are out there, so a preset shows what it has done.
val given = Repo.store.ious.collectAsState().value.values.count {
it.metadata == template.metadata && (it.chain.firstOrNull() as? Block.Issue)?.debtor == Repo.me && it.chain.size > 1
}
if (given > 0) {
Text(
pluralStringResource(R.plurals.template_given, given, given),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
}
}
}
@@ -0,0 +1,112 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouState
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
/**
* OwUs that are over. They are kept - a spent promise is the proof it was
* kept - but they are history, so they live here rather than among the OwUs
* that still mean something.
*/
@Composable
fun RedeemedScreen(nav: NavHostController) = IouListPage(
nav = nav,
title = stringResource(R.string.redeemed_title),
empty = stringResource(R.string.redeemed_empty),
) { _, state -> isSpent(state) }
/**
* What you owe: promises of yours that somebody else is holding. They are
* somebody else's to bring back, so they are not among the OwUs you can act
* on - but you should be able to look them in the eye.
*/
@Composable
fun OwedScreen(nav: NavHostController) = IouListPage(
nav = nav,
title = stringResource(R.string.owed_title),
empty = stringResource(R.string.owed_empty),
) { _, state -> owedByMe(state) }
/** A promise of mine that somebody else is holding. */
internal fun owedByMe(state: IouState): Boolean =
state.status != Status.REDEEMED && state.debtor == Repo.me && state.holder != Repo.me
/** A promise that has been kept, and is now only a record. */
internal fun isSpent(state: IouState): Boolean = state.status == Status.REDEEMED
/** A screen that is one filtered list of OwUs and nothing else. */
@Composable
private fun IouListPage(
nav: NavHostController,
title: String,
empty: String,
include: (Iou, IouState) -> Boolean,
) {
val ious by Repo.store.ious.collectAsState()
val rows = remember(ious, title) {
ious.values.mapNotNull { iou -> Verifier.verify(iou).stateOrNull?.let { iou to it } }
.filter { include(it.first, it.second) }
.sortedByDescending { it.first.head.timestamp }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (rows.isEmpty()) {
Text(
empty,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.Center).padding(32.dp),
)
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(rows.size) { i ->
val (iou, state) = rows[i]
IouRow(iou, state) { nav.navigate("iou/${iou.id}") }
}
}
}
}
}
@@ -0,0 +1,326 @@
package net.helcel.owu.activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import net.helcel.owu.R
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.helper.Locator
import net.helcel.owu.helper.toast
import net.helcel.owu.ledger.GeoLoc
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.store.Template
import net.helcel.owu.store.Repo
import java.util.UUID
/**
* Write a promise. Two jobs, differing in what is left behind.
*
* A **template** ([asTemplate]) is kept as written and signs nothing; an OwU is
* minted from it each time it goes on a table. [templateId] is null for a new
* one, else the one being edited.
*
* A **one-off** is signed there and then and nothing is filed: into your own
* hands from the home screen, onto the table if written at one.
*/
@Composable
fun IssueScreen(nav: NavHostController, templateId: String? = null, asTemplate: Boolean = false) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val template = remember(templateId) { templateId?.let { Repo.store.templates.value[it] } }
val id = remember { template?.id ?: UUID.randomUUID().toString() }
var title by remember { mutableStateOf(template?.metadata?.title ?: "") }
var description by remember { mutableStateOf(template?.metadata?.description ?: "") }
val geo = template?.metadata?.geoloc
var hasGeo by remember { mutableStateOf(geo != null) }
var geoLabel by remember { mutableStateOf(geo?.label ?: "") }
var lat by remember { mutableStateOf(geo?.latitude?.toString() ?: "") }
var lon by remember { mutableStateOf(geo?.longitude?.toString() ?: "") }
var radius by remember { mutableStateOf(geo?.radiusM?.toString() ?: "200") }
var locating by remember { mutableStateOf(false) }
// Backing out of a trade's new promise leaves nothing pending.
DisposableEffect(Unit) { onDispose { TradeIntent.pending = null } }
var notBefore by remember { mutableStateOf(template?.metadata?.notBefore) }
var notAfter by remember { mutableStateOf(template?.metadata?.notAfter) }
var hasWindow by remember { mutableStateOf(template?.metadata?.hasWindow == true) }
// The keyboard walks through the form: every field offers "next" and
// moves focus on, and the last one offers "done" and puts the keyboard
// away. Next, not Down: latitude and longitude sit side by side, and
// "down" from latitude skipped past longitude to the radius below.
val focus = LocalFocusManager.current
val onwards = KeyboardActions(
onNext = { focus.moveFocus(FocusDirection.Next) },
onDone = { focus.clearFocus() },
)
fun stepping(last: Boolean, type: KeyboardType = KeyboardType.Text) = KeyboardOptions(
keyboardType = type,
imeAction = if (last) ImeAction.Done else ImeAction.Next,
)
fun parsedGeo(): GeoLoc? {
if (!hasGeo) return null
val la = lat.replace(',', '.').toDoubleOrNull() ?: return null
val lo = lon.replace(',', '.').toDoubleOrNull() ?: return null
val r = radius.toIntOrNull() ?: return null
if (la !in -90.0..90.0 || lo !in -180.0..180.0 || r <= 0) return null
return GeoLoc.of(la, lo, r, geoLabel.ifBlank { null })
}
fun metadata() = Metadata(
title = title.trim(),
description = description.trim().ifBlank { null },
geoloc = parsedGeo(),
notBefore = notBefore.takeIf { hasWindow },
notAfter = notAfter.takeIf { hasWindow },
)
fun save() {
if (title.isBlank()) {
context.toast(context.getString(R.string.issue_need_title)); return
}
if (hasGeo && parsedGeo() == null) {
context.toast(context.getString(R.string.issue_bad_geo)); return
}
if (hasWindow && notBefore != null && notAfter != null && notBefore!! > notAfter!!) {
context.toast(context.getString(R.string.issue_bad_window)); return
}
val saved = metadata()
if (asTemplate) {
Repo.store.putTemplate(Template(id, saved, template?.createdAt ?: Ledger.now()))
} else {
// A one-off is signed now. At a table it is minted straight onto
// it; otherwise it is minted here, into my own hands, and shows on
// the home screen as mine to give.
val trading = TradeIntent.take()
if (trading != null) PeerManager.readySession(trading.peerKey)?.putNew(saved)
else Repo.store.put(Ledger.issue(Repo.signer, saved))
}
nav.up()
}
fun locate() {
locating = true
scope.launch {
val loc = Locator.current(context)
locating = false
if (loc == null) {
context.toast(context.getString(R.string.geo_unavailable))
} else {
lat = "%.6f".format(loc.latitude)
lon = "%.6f".format(loc.longitude)
}
}
}
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) locate() else context.toast(context.getString(R.string.geo_permission_denied))
}
Scaffold(
topBar = {
TopAppBar(
// The title says which of the two this is: a promise being
// made once, or one being kept for next time.
title = {
Text(
stringResource(
when {
!asTemplate -> R.string.action_new
template == null -> R.string.template_new
else -> R.string.edit_template
}
)
)
},
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (template != null) {
IconButton(onClick = { Repo.store.removeTemplate(template.id); nav.up() }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
}
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
OutlinedTextField(
value = title, onValueChange = { title = it },
label = { Text(stringResource(R.string.field_title)) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = stepping(last = false), keyboardActions = onwards,
)
// Room for the terms, or the occasion, or the joke. Signed with
// the rest, so neither side can rewrite it afterwards.
OutlinedTextField(
value = description, onValueChange = { description = it },
label = { Text(stringResource(R.string.field_description)) },
placeholder = { Text(stringResource(R.string.field_description_hint)) },
minLines = 2, maxLines = 4,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
keyboardOptions = stepping(last = false), keyboardActions = onwards,
)
// Only worth saying when it is going somewhere the moment it is written.
TradeIntent.pending?.let {
Text(
stringResource(R.string.issue_onto_table, Repo.nameOf(it.peerKey)),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 12.dp),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp).clickable { hasGeo = !hasGeo },
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.field_geoloc), style = MaterialTheme.typography.subtitle1)
Text(
stringResource(R.string.field_geoloc_desc),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Switch(checked = hasGeo, onCheckedChange = { hasGeo = it })
}
if (hasGeo) {
OutlinedTextField(
value = geoLabel, onValueChange = { geoLabel = it },
label = { Text(stringResource(R.string.field_geo_label)) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
keyboardOptions = stepping(last = false), keyboardActions = onwards,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedTextField(
value = lat,
onValueChange = { lat = it },
label = { Text(stringResource(R.string.field_lat)) },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = stepping(last = false, type = KeyboardType.Decimal),
keyboardActions = onwards,
)
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = lon,
onValueChange = { lon = it },
label = { Text(stringResource(R.string.field_lon)) },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = stepping(last = false, type = KeyboardType.Decimal),
keyboardActions = onwards,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = radius, onValueChange = { radius = it },
label = { Text(stringResource(R.string.field_radius)) },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = stepping(last = true, type = KeyboardType.Number), keyboardActions = onwards,
)
Spacer(Modifier.size(8.dp))
OutlinedButton(
enabled = !locating,
onClick = { if (Locator.hasPermission(context)) locate() else permission.launch(Locator.PERMISSION) },
) {
Text(stringResource(if (locating) R.string.geo_locating else R.string.geo_use_current))
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp).clickable { hasWindow = !hasWindow },
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.field_window), style = MaterialTheme.typography.subtitle1)
Text(
stringResource(R.string.field_window_desc),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Switch(checked = hasWindow, onCheckedChange = { hasWindow = it })
}
if (hasWindow) {
DateTimeField(
label = stringResource(R.string.field_not_before),
value = notBefore, onChange = { notBefore = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
DateTimeField(
label = stringResource(R.string.field_not_after),
value = notAfter, onChange = { notAfter = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
Button(onClick = { save() }, modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
Text(stringResource(if (TradeIntent.pending != null) R.string.action_save_and_offer else R.string.action_save))
}
}
}
}
@@ -0,0 +1,206 @@
package net.helcel.owu.activity
import android.os.Bundle
import android.app.Activity
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material.AppBarDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.LocalElevationOverlay
import androidx.compose.material.MaterialTheme
import androidx.compose.material.primarySurface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import net.helcel.owu.ble.BlePermissions
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.store.Repo
class MainScreen : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
actionBar?.hide()
// Draw behind the system bars: no colour named in a file can match a
// Material You bar chosen from the wallpaper. Both fully transparent,
// since the default scrims the navigation bar a shade darker and looks
// like a second mismatch; [SystemBars] gives the icons their contrast.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
setContent {
SysTheme {
// What a Material 2 app bar paints itself: primarySurface
// *plus* the dark theme's elevation overlay, which lifts a 4dp
// bar off the background. Miss the overlay and the strip above
// it is a near miss, which reads worse than a plain difference.
val bar = MaterialTheme.colors.primarySurface
val barPainted = LocalElevationOverlay.current
?.apply(bar, AppBarDefaults.TopAppBarElevation) ?: bar
SystemBars(status = barPainted, navigation = MaterialTheme.colors.background)
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
// Status bar in the top bar's colour, the rest in the
// content's. Inside both insets, or the system buttons sit
// on whatever is at the foot of a screen.
Spacer(
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.windowInsetsTopHeight(WindowInsets.statusBars)
.background(barPainted)
)
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Root()
}
}
}
}
}
// BLE only while resumed. Rotation is exempt so trades survive it.
override fun onPause() {
super.onPause()
if (!isChangingConfigurations) PeerManager.pause()
}
/** Bar icons legible against what is behind them. The colour is a Material
* You one chosen on the device, so it is measured, not assumed. */
@Composable
private fun SystemBars(status: Color, navigation: Color) {
val view = LocalView.current
val paleStatus = status.luminance() > 0.5f
val paleNavigation = navigation.luminance() > 0.5f
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = paleStatus
isAppearanceLightNavigationBars = paleNavigation
}
}
}
/** Opens the store and the Keystore key off the main thread, then shows the app. */
@Composable
fun Root() {
val context = LocalContext.current
val ready by Repo.ready.collectAsState()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { Repo.init(context.applicationContext) }
}
if (!ready) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colors.primary,
strokeWidth = 4.dp,
modifier = Modifier.size(50.dp),
)
}
} else {
// BLE on while the app is in use.
val permissions = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted -> if (granted.values.all { it }) PeerManager.resume(context) }
var askedFor by rememberSaveable { mutableStateOf(false) }
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
// Runs only once the store has opened, which on a cold start is
// after ON_RESUME has gone by, and an observer hears only what
// comes next. So ask where the lifecycle is, not just where it goes.
fun begin() {
// Always: keeps the icon current without permission.
PeerManager.resume(context)
if (!BlePermissions.granted(context) && !askedFor) {
askedFor = true
permissions.launch(BlePermissions.required().toTypedArray())
}
}
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) begin()
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) begin()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
// Hoisted out of the nav host: one of the prompts below navigates.
val nav = rememberNavController()
AppNavHost(nav)
// Being asked to pay is worth interrupting for, wherever you are.
RedeemPrompt()
// Somebody opening a table with you is worth interrupting for too.
TradePrompt(nav)
PendingAsk()
// And what came of it: a table can finish with neither side looking.
OutcomeBanner()
}
}
@Composable
fun AppNavHost(nav: NavHostController) {
NavHost(nav, startDestination = "main") {
composable("main") { IouListScreen(nav) }
// Two ways to write a promise: once, or as a template to reuse.
composable("issue") { IssueScreen(nav) }
composable("template") { IssueScreen(nav, asTemplate = true) }
composable("template/{id}") {
IssueScreen(nav, templateId = it.arguments?.getString("id"), asTemplate = true)
}
composable("iou/{id}") { IouDetailScreen(nav, it.arguments?.getString("id") ?: "") }
composable("identity") { IdentityScreen(nav) }
composable("peer/{beacon}") { PeerScreen(nav, it.arguments?.getString("beacon") ?: "") }
composable("settings") {
SettingsMainScreen(
onExit = { nav.up() },
onRedeemed = { nav.navigate("redeemed") },
onOwed = { nav.navigate("owed") },
onTemplates = { nav.navigate("templates") },
)
}
composable("redeemed") { RedeemedScreen(nav) }
composable("owed") { OwedScreen(nav) }
composable("templates") { TemplatesScreen(nav) }
}
}
}
@@ -0,0 +1,134 @@
package net.helcel.owu.activity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import net.helcel.owu.R
import net.helcel.owu.peer.Outcome
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.store.Repo
/**
* What just happened, said out loud. A table used to finish by quietly
* emptying itself, and a redeem answered from a prompt finished with no screen
* at all. This lives at the app root and speaks wherever you are.
*/
@Composable
fun OutcomeBanner() {
// Two states, not one: the card keeps saying what it said while it slides
// away, so the words outlive the showing.
var outcome by remember { mutableStateOf<Outcome?>(null) }
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
PeerManager.outcomes.collect {
outcome = it
visible = true
}
}
LaunchedEffect(outcome) {
if (outcome == null) return@LaunchedEffect
delay(SHOW_MS)
visible = false
}
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.BottomCenter) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
) {
outcome?.let { Banner(it) { visible = false } }
}
}
}
@Composable
private fun Banner(outcome: Outcome, onDismiss: () -> Unit) {
// A refusal is no failure, and must not look like a success either.
val no = outcome.declined != null
val paper = if (no) MaterialTheme.colors.surface else MaterialTheme.colors.primary
val ink = if (no) MaterialTheme.colors.onSurface else MaterialTheme.colors.onPrimary
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onDismiss),
elevation = 8.dp,
backgroundColor = paper,
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
if (no) Icons.Default.Close else Icons.Default.Check,
contentDescription = null,
tint = paper,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ink.copy(alpha = if (no) 0.5f else 1f))
.padding(4.dp),
)
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
headline(outcome),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Medium,
color = ink,
)
outcome.peer?.let {
Text(
stringResource(if (no) R.string.from_x else R.string.done_with, Repo.nameOf(it)),
style = MaterialTheme.typography.caption,
color = ink.copy(alpha = 0.7f),
)
}
}
}
}
}
/** The one sentence for it: redeeming first, since that is what ends a loop. */
@Composable
private fun headline(outcome: Outcome): String = when {
outcome.declined == PeerMessage.Asked.INVITE -> stringResource(R.string.done_declined_trade)
outcome.declined != null -> stringResource(R.string.done_declined_ask)
outcome.redeemed.isNotEmpty() -> stringResource(R.string.done_redeemed, bundleLine(outcome.redeemed))
outcome.gave.isNotEmpty() && outcome.got.isNotEmpty() ->
stringResource(R.string.done_traded, bundleLine(outcome.gave), bundleLine(outcome.got))
outcome.gave.isNotEmpty() -> stringResource(R.string.done_gave, bundleLine(outcome.gave))
else -> stringResource(R.string.done_got, bundleLine(outcome.got))
}
/** Long enough to read, short enough not to be in the way. */
private const val SHOW_MS = 4_000L
@@ -0,0 +1,129 @@
package net.helcel.owu.activity
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random
/**
* The app's face, drawn rather than stamped from the launcher icon so it can
* do the one thing an icon cannot: now and then the left eye closes and OwU
* becomes UwU. Both eyes are one shape ([eye]) at different points of the same
* animation. Tapping winks on demand; left alone it winks rarely.
*/
@Composable
fun OwuFace(modifier: Modifier = Modifier) {
val lid = remember { Animatable(0f) } // 0 = open (O), 1 = shut (U)
val scope = rememberCoroutineScope()
suspend fun wink() {
lid.animateTo(1f, tween(160))
delay(340)
lid.animateTo(0f, tween(240))
}
// Rarely, and never on a schedule anyone could set a watch by.
LaunchedEffect(Unit) {
while (true) {
delay(Random.nextLong(9_000, 24_000))
wink()
}
}
val face = MaterialTheme.colors.onPrimary
val disc = MaterialTheme.colors.primary
Canvas(
modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { scope.launch { wink() } }
.semantics { contentDescription = "OwU" },
) {
val unit = size.minDimension / 108f
fun p(v: Float) = v * unit
val width = p(5f)
// One colour, edge to edge, and the face on it - what the launcher
// icon is, minus the shape the launcher itself decides.
drawRect(disc)
scale(0.88f, pivot = Offset(p(54f), p(52f))) {
// Both eyes stand on the same baseline, y = 60, and reach y = 44.
eye(cx = p(32f), cy = p(52f), r = p(8f), shut = lid.value, color = face, width = width)
eye(cx = p(76f), cy = p(52f), r = p(8f), shut = 1f, color = face, width = width)
// The mouth stands on the same baseline as the eyes, with a gap
// either side so the three glyphs read as three.
drawPath(
path = Path().apply {
moveTo(p(47f), p(50f))
lineTo(p(50.5f), p(60f))
lineTo(p(54f), p(52f))
lineTo(p(57.5f), p(60f))
lineTo(p(61f), p(50f))
},
color = face,
style = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
}
}
/**
* One eye between open and shut, in two strokes: the U - bottom half-circle
* and two vertical stems - and the lid arcing over it. Closing raises the
* stems and lets the lid sink onto them, fading. The bottom never moves, the
* sides stay vertical, and [shut] = 1 is exactly the other eye's U: an eye
* shuts, it does not shrink or square off.
*/
private fun DrawScope.eye(cx: Float, cy: Float, r: Float, shut: Float, color: Color, width: Float) {
val stroke = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round)
// Up fast, so the U is there before the lid has finished leaving.
val stem = r * (1f - (1f - shut) * (1f - shut))
drawPath(
Path().apply {
moveTo(cx - r, cy - stem)
lineTo(cx - r, cy)
// Left, round the bottom, to the right.
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 180f, -180f, false)
lineTo(cx + r, cy - stem)
},
color,
style = stroke,
)
if (shut >= 1f) return
val bulge = r * (1f - shut)
drawPath(
Path().apply {
moveTo(cx - r, cy - stem)
// Handles four thirds of the bulge up: the usual approximation of
// a half circle, and a shallower arc as the lid comes down.
cubicTo(cx - r, cy - stem - bulge * 4f / 3f, cx + r, cy - stem - bulge * 4f / 3f, cx + r, cy - stem)
},
color,
alpha = (1f - shut) * (1f - shut),
style = stroke,
)
}
@@ -0,0 +1,316 @@
package net.helcel.owu.activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.helper.Gates
import net.helcel.owu.helper.Locator
import net.helcel.owu.helper.toast
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Contact
import net.helcel.owu.store.Repo
/** One person, once picked: the table between us. Opening this screen opens
* the connection and leaving it hangs up again. */
@Composable
fun PeerScreen(nav: NavHostController, beaconHex: String) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val st by PeerManager.state.collectAsState()
val session = st.peers[beaconHex]
if (session == null) {
// They walked away while we were looking at them.
LaunchedEffect(Unit) { nav.up() }
return
}
val ss by session.state.collectAsState()
val ious by Repo.store.ious.collectAsState()
val contacts by Repo.store.contacts.collectAsState()
val templates by Repo.store.templates.collectAsState()
val peer = ss.peer
val known = peer != null && contacts.any { it.publicKey == peer.key }
// Until the handshake finishes there is no identity to read a name from,
// and you arrived here by tapping somebody by name: say their name. The
// beacon is derived from their key, so this is the contact you picked,
// not a guess - it is only the *proof* that is still a second away.
val who = peerLabel(peer, known).ifBlank {
contacts.firstOrNull { Ble.beacon(it.publicKey).contentEquals(session.beacon) }?.name.orEmpty()
}
var connecting by remember { mutableStateOf(true) }
var linkError by remember { mutableStateOf(false) }
LaunchedEffect(beaconHex) {
connecting = true
linkError = !PeerManager.select(session.beacon)
connecting = false
}
// Once we are known to each other, tell them we are here: they get a
// prompt wherever they are, rather than having to guess that somebody is
// standing at a table waiting for them.
LaunchedEffect(beaconHex, ss.phase) {
if (ss.phase == PeerSession.Phase.READY) session.invite()
}
DisposableEffect(beaconHex) {
onDispose { PeerManager.release(session.beacon) }
}
var addName by remember { mutableStateOf<String?>(null) }
var pickMine by remember { mutableStateOf(false) }
// OwUs on the table we are not at the place of, so the table can say so
// in red. It is never a bar - the one who owes it decides.
var outOfPlace by remember { mutableStateOf(emptySet<String>()) }
val ready = ss.phase == PeerSession.Phase.READY && !connecting
// Verified, connected and nothing in the way: the state the tick stands for.
val settled = ready && !linkError
// Everything I could put down, minted ious for this table included: the
// picker shows what is already down as chosen rather than hiding it.
val mineHeld = remember(ious) {
ious.values.filter {
Verifier.verify(it).stateOrNull?.let { s -> s.holder == Repo.me && s.status == Status.ACTIVE } == true
}
}
// Putting ious back on the table with the person who owes them *is*
// redeeming: they go home and they close them.
val redeeming = ss.table.mine.isNotEmpty() &&
ss.table.mine.all { Verifier.verify(it).stateOrNull?.debtor == peer?.key }
// A redeem begun from the OwU itself: put it down as soon as this table
// can take it, so the holder does not have to find it again in the picker.
LaunchedEffect(ready, peer?.key) {
val key = peer?.key
if (ready && key != null) {
TradeIntent.takeOffer(key)?.let { offer ->
Repo.store.ious.value[offer.iouId]?.let { session.put(held = listOf(it)) }
}
}
}
val tableIds = (ss.table.mine + ss.table.theirs).joinToString(",") { it.id }
LaunchedEffect(tableIds) {
outOfPlace = (ss.table.mine + ss.table.theirs)
.filter { Gates.outOfPlace(context, it) }
.map { it.id }
.toSet()
}
val locationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) context.toast(context.getString(R.string.geo_permission_denied))
}
// --- dialogs -----------------------------------------------------------
addName?.let { name ->
AlertDialog(
onDismissRequest = { addName = null },
title = { Text(stringResource(R.string.contact_name_this)) },
text = {
Column {
OutlinedTextField(
value = name, onValueChange = { addName = it },
label = { Text(stringResource(R.string.name)) }, singleLine = true
)
Text(
stringResource(R.string.contact_name_hint),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = {
Repo.store.putContact(Contact(peer!!.key, name.trim()))
addName = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = { addName = null }) { Text(stringResource(R.string.cancel)) } },
)
}
if (pickMine) TablePicker(
templates = templates.values.sortedByDescending { it.createdAt },
held = mineHeld,
onTable = ss.table.mine,
onDismiss = { pickMine = false },
onWrite = {
pickMine = false
TradeIntent.pending = TradeIntent.Pending(peer!!.key)
nav.navigate("issue")
},
onPut = { ious, mint ->
pickMine = false
session.put(held = ious, mint = mint)
// Judging a place gate needs the permission; ask while there is
// still time, not at the moment of saying yes.
val placed = ious.any { it.metadata.geoloc != null } || mint.any { it.geoloc != null }
if (placed && !Locator.hasPermission(context)) locationPermission.launch(Locator.PERMISSION)
},
)
// --- screen ------------------------------------------------------------
Scaffold(
topBar = {
TopAppBar(
// The tick belongs beside whoever they are, which is the name
// when you have one and the key when you do not - and that is
// the title either way.
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(who.ifBlank { stringResource(R.string.peer_unknown) })
if (settled) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.peer_verified),
tint = LocalContentColor.current,
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// The key under the name, when the name is not the key already.
peerFingerprint(peer, who)?.let {
Text(
it, style = MaterialTheme.typography.caption,
fontFamily = FontFamily.Monospace, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
impersonating(peer)?.let {
Text(
it, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.error,
modifier = Modifier.padding(top = 4.dp)
)
}
// Only for somebody you have not named yet: this is the one place
// a peer met over the air can become a contact. Renaming one you
// already have belongs on the identity screen, where they live.
if (peer != null && !known) {
OutlinedButton(
onClick = { addName = peer.name },
modifier = Modifier.padding(top = 8.dp),
) {
Text(stringResource(R.string.contact_name_this))
}
}
// Only while something is still in the way; once it is settled the
// tick above says so and this row goes.
if (!settled) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
if (connecting || ss.phase == PeerSession.Phase.HANDSHAKE) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(8.dp))
}
Text(
when {
linkError -> stringResource(R.string.peer_no_link)
connecting -> stringResource(R.string.peer_connecting)
ss.phase == PeerSession.Phase.GONE -> stringResource(R.string.peer_gone)
else -> stringResource(R.string.peer_verifying)
},
style = MaterialTheme.typography.body2,
color = if (linkError || ss.phase == PeerSession.Phase.GONE) MaterialTheme.colors.error
else MaterialTheme.colors.primary,
)
}
}
Card(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), elevation = 2.dp) {
TradeTable(
table = ss.table,
theirName = who,
theirKey = peer?.key,
enabled = ready,
redeeming = redeeming,
outOfPlace = outOfPlace,
onPut = { pickMine = true },
onAccept = { session.accept() },
modifier = Modifier.padding(12.dp),
)
}
if (ss.waiting != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
Text(
stringResource(R.string.peer_waiting, ss.waiting ?: ""), style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), modifier = Modifier.weight(1f)
)
TextButton(onClick = { session.stopWaiting() }) { Text(stringResource(R.string.peer_stop_waiting)) }
}
}
if (ss.log.isNotEmpty()) {
Divider(modifier = Modifier.padding(vertical = 12.dp))
ss.log.takeLast(8).forEach {
Text(
it,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
}
@@ -0,0 +1,90 @@
package net.helcel.owu.activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.helper.toast
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Repo
/**
* Every ask to redeem, from the tap onward. The button writes down who is
* asked for what; finding them, connecting, the handshake and the sending all
* happen here at the app root, whatever screen the user went on to. The other
* phone need not have been found, or even be switched on.
*
* The ask keeps until it is *on their table*, not until sent: two phones can
* disagree about having been introduced, and putting that right costs the
* table it carried.
*/
@Composable
fun PendingAsk() {
val context = LocalContext.current
val waiting = TradeIntent.offer
// A "no" ends it, or the loop below re-asks every few seconds and the two
// phones refuse each other for as long as they share a room.
LaunchedEffect(Unit) {
PeerManager.outcomes.collect { outcome ->
if (outcome.declined == PeerMessage.Asked.TABLE && outcome.peer == TradeIntent.offer?.peerKey) {
TradeIntent.offer = null
}
}
}
// Keyed on *what* is asked, not the session: a peer already in the list
// never changes that object, so an effect keyed on it never runs.
LaunchedEffect(waiting?.peerKey, waiting?.iouId) {
val offer = waiting ?: return@LaunchedEffect
val hex = Ble.beaconHex(offer.peerKey)
var told = false
while (true) {
if (TradeIntent.offer != offer) return@LaunchedEffect
// Redeemed, given away or deleted: not ours to ask about any more.
val iou = Repo.store.ious.value[offer.iouId]
val state = iou?.let { Verifier.verify(it).stateOrNull }
if (state == null || state.holder != Repo.me || state.status != Status.ACTIVE) {
TradeIntent.offer = null
return@LaunchedEffect
}
val session = PeerManager.state.value.peers[hex]
if (session != null && !onTheTable(session, offer.iouId)) {
val ready = withTimeoutOrNull(ASK_WAIT_MS) {
PeerManager.select(session.beacon) &&
session.state.first { it.phase == PeerSession.Phase.READY }.let { true }
} == true
if (ready) {
session.offer(listOf(iou))
// Once per ask: the loop may send twice, the user asked once.
if (!told) {
context.toast(context.getString(R.string.redeem_asked, Repo.nameOf(offer.peerKey)))
told = true
}
}
}
delay(RETRY_MS)
}
}
}
/** Down on our side of their table, and accepted: the ask is with them. */
private fun onTheTable(session: PeerSession, iouId: String): Boolean {
val table = session.state.value.table
return table.mine.size == 1 && table.mine[0].id == iouId && table.accepted
}
/** Long enough for two phones to say hello over a fresh connection. */
private const val ASK_WAIT_MS = 12_000L
/** How often to look again. Short: usually the peer is right there and simply
* had not been heard from when the button was pressed. */
private const val RETRY_MS = 3_000L
@@ -0,0 +1,85 @@
package net.helcel.owu.activity
import androidx.compose.foundation.layout.Column
import androidx.compose.material.AlertDialog
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import net.helcel.owu.R
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Repo
/**
* Somebody is asking to redeem a promise of yours. It is the table like
* anything else, but with nothing to choose: they have put it down and said
* yes, so the only question is whether to accept. Asked here, wherever the
* debtor is, rather than on a trade screen holding one thing.
*/
@Composable
fun RedeemPrompt() {
val st by PeerManager.state.collectAsState()
// Asks turned down stay down until the table changes.
val refused = remember { mutableStateListOf<String>() }
var asking: Pair<PeerSession, List<Iou>>? = null
val live = mutableListOf<String>()
st.peers.forEach { (hex, session) ->
key(hex) {
val ss by session.state.collectAsState()
val theirs = ss.table.theirs
// Theirs alone, all of them mine, their yes given: a redemption.
val isAsk = theirs.isNotEmpty() && ss.table.mine.isEmpty() &&
ss.table.theyAccepted && !ss.table.accepted && !ss.table.conflict &&
theirs.all { Verifier.verify(it).stateOrNull?.debtor == Repo.me }
if (isAsk) {
live += ticket(hex, theirs)
if (ticket(hex, theirs) !in refused && asking == null) asking = session to theirs
}
}
}
// A refusal lasts as long as the ask it refused. Held for good, "Not now"
// would swallow every later attempt at the same promise.
LaunchedEffect(live.joinToString("|")) { refused.retainAll(live) }
asking?.let { (session, ious) ->
// The key checks the ious, so an ask stands with or without a name.
val who = session.state.value.peer?.let { Repo.nameOf(it.key) } ?: stringResource(R.string.peer_unknown)
AlertDialog(
onDismissRequest = { refused += ticket(net.helcel.owu.ble.Ble.hex(session.beacon), ious) },
title = { Text(stringResource(R.string.redeem_ask, who)) },
text = {
Column {
Text(bundleLine(ious), style = MaterialTheme.typography.h6)
ious.firstOrNull()?.let { MetaGates(it.metadata, homecoming = true) }
}
},
confirmButton = {
TextButton(onClick = { session.accept() }) { Text(stringResource(R.string.action_accept)) }
},
dismissButton = {
TextButton(onClick = {
refused += ticket(net.helcel.owu.ble.Ble.hex(session.beacon), ious)
// Say so: their side clears the ask with ours, so they get
// the Redeem button back instead of waiting on you.
session.decline(PeerMessage.Asked.TABLE)
}) { Text(stringResource(R.string.redeem_later)) }
},
)
}
}
/** What was refused: this peer asking for exactly these ious. */
private fun ticket(hex: String, ious: List<Iou>): String =
hex + ious.map { it.id }.sorted().joinToString(",")
@@ -0,0 +1,232 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import net.helcel.owu.R
import net.helcel.owu.activity.sub.AboutScreen
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
@Preview
@Composable
fun SettingsMainScreen(
onExit: () -> Unit = {},
onRedeemed: () -> Unit = {},
onOwed: () -> Unit = {},
onTemplates: () -> Unit = {},
) {
val nav: NavHostController = rememberNavController()
SysTheme {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_settings)) },
navigationIcon = {
IconButton(onClick = {
if (!nav.popBackStack())
onExit()
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
) { innerPadding ->
// One host, inside the scaffold. Building it here and again as a
// default argument left two of them: settings drawn twice, and
// only the inner one ever navigating.
Box(modifier = Modifier.padding(innerPadding)) {
NavHost(nav, startDestination = "settings") {
composable("settings") { SettingsScreen(nav, onRedeemed, onOwed, onTemplates) }
composable("about") { AboutScreen() }
}
}
}
}
}
/**
* Four kinds of thing in one screen - lists to open, a switch, a choice, and
* two actions - so each kind gets one shape and keeps it: rows that open
* something look like rows, and only the backup buttons look like buttons.
*/
@Composable
fun SettingsScreen(
navController: NavHostController,
onRedeemed: () -> Unit = {},
onOwed: () -> Unit = {},
onTemplates: () -> Unit = {},
) {
val context = LocalContext.current
// What each list holds, counted the same way the list itself filters.
val ious by Repo.store.ious.collectAsState()
val templates by Repo.store.templates.collectAsState()
val (owed, spent) = remember(ious) {
val states = ious.values.mapNotNull { Verifier.verify(it).stateOrNull }
states.count(::owedByMe) to states.count(::isSpent)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
) {
// OwUs you cannot act on from the home screen - what you keep ready,
// what you owe, and what is spent - are worth looking up.
Section(stringResource(R.string.pref_category_notes)) {
NavRow(stringResource(R.string.section_templates), templates.size, onTemplates)
NavRow(stringResource(R.string.owed_title), owed, onOwed)
NavRow(stringResource(R.string.redeemed_title), spent, onRedeemed)
}
Section(stringResource(R.string.key_theme)) {
Segmented(
listOf(
stringResource(R.string.system),
stringResource(R.string.light),
stringResource(R.string.dark),
),
ThemeChoice.current(context),
) { picked -> ThemeChoice.set(context, picked) }
}
Section(stringResource(R.string.pref_category_data)) {
BackupSection()
}
Spacer(Modifier.height(16.dp))
PreferenceButton(stringResource(R.string.about)) {
if (navController.currentDestination?.route != "about")
navController.navigate("about")
}
}
}
/** A heading in the app's own section style, and whatever belongs under it. */
@Composable
private fun Section(title: String, content: @Composable () -> Unit) {
SectionHeader(title)
content()
}
/** A row that opens a list: what it is, how much is in it, and a chevron. */
@Composable
private fun NavRow(title: String, count: Int, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 14.dp),
) {
Text(
title,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.weight(1f),
)
// An empty list says so when you open it; no need for a nought here.
if (count > 0) {
Text(
count.toString(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
}
/** One of a few, side by side: shorter than a stack of radio buttons. */
@Composable
private fun Segmented(options: List<String>, selected: String, onSelect: (String) -> Unit) {
val shape = RoundedCornerShape(8.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(shape)
.border(1.dp, MaterialTheme.colors.primary.copy(alpha = 0.4f), shape)
) {
options.forEach { option ->
val on = option == selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.height(44.dp)
.background(if (on) MaterialTheme.colors.primary else Color.Transparent)
.clickable { onSelect(option) },
) {
Text(
option,
style = MaterialTheme.typography.button,
color = if (on) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground,
)
}
}
}
}
/** The one thing here that is not a setting, and so is not a row. */
@Composable
fun PreferenceButton(text: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) { Text(text) }
}
@@ -0,0 +1,81 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.store.Repo
/**
* The promises you keep ready to make. Nothing here is owed to anybody: each
* is a form waiting to be signed, and putting one on a table mints an OwU
* from it.
*/
@Composable
fun TemplatesScreen(nav: NavHostController) {
val templates by Repo.store.templates.collectAsState()
val rows = templates.values.sortedByDescending { it.createdAt }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.section_templates)) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.navigate("template") }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.template_new))
}
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (rows.isEmpty()) {
Text(
stringResource(R.string.templates_empty),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.Center).padding(32.dp),
)
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(rows.size) { i ->
val template = rows[i]
TemplateRow(template) { nav.navigate("template/${template.id}") }
}
item { Spacer(Modifier.size(80.dp)) } // room under the FAB
}
}
}
}
@@ -0,0 +1,140 @@
package net.helcel.owu.activity
import android.app.WallpaperManager
import android.content.Context
import android.os.Build
import android.graphics.Color as AndroidColor
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
import net.helcel.owu.R
import net.helcel.owu.helper.defaultPreferences
/**
* Which theme the user picked. It is state as well as a preference, because
* picking one has to repaint the screen it was picked on, and a preference
* read during composition never comes back to say it has changed.
*/
object ThemeChoice {
private var chosen by mutableStateOf<String?>(null)
fun current(context: Context): String = chosen ?: stored(context)
fun set(context: Context, value: String) {
defaultPreferences(context).edit { putString(context.getString(R.string.key_theme), value) }
chosen = value
}
private fun stored(context: Context): String = defaultPreferences(context).getString(
context.getString(R.string.key_theme),
context.getString(R.string.system),
)!!
}
/**
* The wallpaper's accent, where the platform will not give us a palette.
* `getWallpaperColors` has been there since API 27 - below our own minimum -
* and asks no permission, while Material You only arrives at 31. Null if
* there is nothing to read.
*/
private fun wallpaperAccent(context: Context): Color? {
val argb = runCatching {
WallpaperManager.getInstance(context)
.getWallpaperColors(WallpaperManager.FLAG_SYSTEM)?.primaryColor?.toArgb()
}.getOrNull() ?: return null
return Color(argb)
}
/**
* The baseline scheme with its primary family re-hued to [seed].
*
* Not a tonal palette - that wants a library this app does without - so only
* the hue and some of the saturation are the wallpaper's. Brightness is forced
* into the band Material uses for the theme, which is what keeps text legible
* on it whatever the wallpaper happens to be.
*/
private fun ColorScheme.accented(seed: Color, dark: Boolean): ColorScheme {
val hsv = FloatArray(3)
AndroidColor.colorToHSV(seed.toArgb(), hsv)
fun of(saturation: Float, value: Float) =
Color(AndroidColor.HSVToColor(floatArrayOf(hsv[0], saturation.coerceIn(0f, 1f), value)))
val s = hsv[1]
return if (dark) copy(
primary = of(s.coerceIn(0.10f, 0.55f), 0.92f),
onPrimary = Color.Black,
primaryContainer = of(s.coerceIn(0.15f, 0.70f), 0.35f),
onPrimaryContainer = of(s * 0.3f, 0.95f),
secondary = of(s * 0.4f, 0.85f),
) else copy(
primary = of(s.coerceIn(0.25f, 0.90f), 0.55f),
onPrimary = Color.White,
primaryContainer = of(s * 0.25f, 0.96f),
onPrimaryContainer = of(s.coerceIn(0.25f, 0.90f), 0.25f),
secondary = of(s * 0.4f, 0.50f),
)
}
@Composable
fun SysTheme(
content: @Composable () -> Unit
) {
val context = LocalContext.current
val themeKey = ThemeChoice.current(context)
val darkTheme = when (themeKey) {
stringResource(R.string.system) -> isSystemInDarkTheme()
stringResource(R.string.light) -> false
stringResource(R.string.dark) -> true
else -> isSystemInDarkTheme()
}
val colorScheme = when {
// Material You: the platform hands us a whole palette from the wallpaper.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
// Below that there is no palette to ask for, but the wallpaper's own
// accent is still readable, so the app is at least the right colour.
else -> {
val baseline = if (darkTheme) darkColorScheme() else lightColorScheme()
wallpaperAccent(context)?.let { baseline.accented(it, darkTheme) } ?: baseline
}
}
val m2colors = Colors(
primary = colorScheme.primary,
primaryVariant = colorScheme.primaryContainer,
secondary = colorScheme.secondary,
background = colorScheme.background,
surface = colorScheme.surface,
onPrimary = colorScheme.onPrimary,
onSecondary = colorScheme.onSecondary,
onBackground = colorScheme.onBackground,
onSurface = colorScheme.onSurface,
secondaryVariant = colorScheme.secondary,
error = colorScheme.error,
onError = colorScheme.onError,
isLight = !darkTheme,
)
// Both, because the app mixes the two: most of it is Material 2, while the
// dividers are Material 3 and would otherwise fall back to the default
// light scheme.
androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
MaterialTheme(
colors = m2colors,
content = content
)
}
}
@@ -0,0 +1,26 @@
package net.helcel.owu.activity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** What a screen was opened for, on the way to somebody's table. Gone once claimed. */
object TradeIntent {
/** The new promise being written belongs on this peer's table. */
data class Pending(val peerKey: String)
// Compose state, not plain fields: a plain var gives screens no reason to look again.
var pending: Pending? by mutableStateOf(null)
fun take(): Pending? = pending.also { pending = null }
/** An OwU to put down once the table with [peerKey] opens: how a redeem
* begun from the OwU finds its table without hunting in the picker. */
data class Offer(val peerKey: String, val iouId: String)
var offer: Offer? by mutableStateOf(null)
/** Claims the waiting offer, if it is for this peer. */
fun takeOffer(peerKey: String): Offer? =
offer?.takeIf { it.peerKey == peerKey }?.also { offer = null }
}
@@ -0,0 +1,65 @@
package net.helcel.owu.activity
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import net.helcel.owu.R
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Repo
/**
* Somebody has opened a table with you; without this they would have to be on
* the right screen at the right moment to find out. Same shape as
* [RedeemPrompt]. Only from people you have named: trading is contacts-only,
* and a dialog any passer-by could raise on your phone is a nuisance.
*/
@Composable
fun TradePrompt(nav: NavHostController) {
val st by PeerManager.state.collectAsState()
val contacts by Repo.store.contacts.collectAsState()
val route = nav.currentBackStackEntryAsState().value?.destination?.route
var asking: Pair<String, PeerSession>? = null
st.peers.forEach { (hex, session) ->
key(hex) {
val ss by session.state.collectAsState()
val known = ss.peer?.key?.let { k -> contacts.any { it.publicKey == k } } == true
// Already at their table: being there is the answer.
val attending = route == "peer/{beacon}" &&
nav.currentBackStackEntry?.arguments?.getString("beacon") == hex
if (ss.invited && attending) LaunchedEffect(hex) { session.inviteAnswered() }
else if (ss.invited && known && asking == null) asking = hex to session
}
}
asking?.let { (hex, session) ->
val who = session.state.value.peer?.let { Repo.nameOf(it.key) } ?: stringResource(R.string.peer_unknown)
AlertDialog(
// Dismissing is not a refusal to report; only "Not now" tells them.
onDismissRequest = { session.inviteAnswered() },
title = { Text(stringResource(R.string.trade_ask, who)) },
confirmButton = {
TextButton(onClick = {
session.inviteAnswered()
nav.navigate("peer/$hex")
}) { Text(stringResource(R.string.trade_accept)) }
},
dismissButton = {
TextButton(onClick = {
session.inviteAnswered()
session.decline(PeerMessage.Asked.INVITE)
}) { Text(stringResource(R.string.redeem_later)) }
},
)
}
}
@@ -0,0 +1,602 @@
package net.helcel.owu.activity
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.text.format.DateFormat
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentColor
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.crypto.Keys
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.helper.formatTime
import net.helcel.owu.peer.PeerEngine
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.TimeGate
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
import net.helcel.owu.store.Template
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
// Pieces shared by more than one screen.
@Composable
fun statusLabel(status: Status): String = when (status) {
Status.ACTIVE -> stringResource(R.string.status_active)
Status.REDEEMED -> stringResource(R.string.status_redeemed)
}
@Composable
fun peerLabel(peer: IdentityCard?, known: Boolean): String = when {
peer == null -> ""
known -> Repo.nameOf(peer.key)
else -> peer.name.ifBlank { Keys.fingerprint(peer.key) }
}
/** Set when a peer calls itself a name you know somebody else by. Names are
* self-asserted, keys are not: the one place a stranger can try to pass. */
@Composable
fun impersonating(peer: IdentityCard?): String? {
if (peer == null || peer.name.isBlank()) return null
val contacts by Repo.store.contacts.collectAsState()
val clash = contacts.firstOrNull { it.name.equals(peer.name.trim(), ignoreCase = true) && it.publicKey != peer.key }
return clash?.let { stringResource(R.string.peer_name_clash, it.name) }
}
/** A tick beside a name whose key we keep: that person's promise, not merely
* a valid signature by a stranger. A missing tick is the whole message. */
@Composable
fun KnownMark(publicKey: String, modifier: Modifier = Modifier) {
if (!Repo.knows(publicKey)) return
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.in_your_contacts),
tint = MaterialTheme.colors.primary,
modifier = modifier.padding(start = 4.dp).size(14.dp),
)
}
/** The key under a name, unless the name *is* the key already. */
@Composable
fun peerFingerprint(peer: IdentityCard?, label: String): String? =
peer?.let { Keys.fingerprint(it.key) }?.takeIf { it != label }
/**
* What to put on the table: nothing, promises minted from templates, or OwUs
* you hold - any number, in one bundle.
*
* A sheet, not a dialog: the list has no known length, and as a dialog it grew
* to fill the screen badly. Writing a new promise leads, being the one entry
* that is an action; templates follow, the common case being the same beer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TablePicker(
templates: List<Template>,
held: List<Iou>,
/** What is already on my side, so the sheet opens on the bundle as it stands. */
onTable: List<Iou>,
onDismiss: () -> Unit,
onWrite: () -> Unit,
/** The bundle as chosen: ious I hold, and one mint per template copy. */
onPut: (List<Iou>, List<Metadata>) -> Unit,
) {
// A minted beer on the table counts as one beer of its template, so
// reopening the sheet shows what is down rather than starting empty.
val down = remember(onTable) { onTable.map { it.id }.toSet() }
val counts = remember(onTable) { mutableStateMapOf<String, Int>() }
val chosen = remember(onTable) { mutableStateListOf<String>().apply { addAll(down) } }
val total = counts.values.sum() + chosen.size
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
) {
// M3 sheet, M2 content, separate LocalContentColors: M3's contentColor
// never reaches an M2 Text, which defaults to black and vanishes on a
// dark sheet. Hand M2 the same colour.
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colors.onSurface) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Text(
stringResource(R.string.table_pick_mine),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 4.dp),
)
// Weighted, not filled: content-sized while it fits, scrolling
// once it does not, so the buttons stay in reach.
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
PickerRow(
title = stringResource(R.string.table_new_promise),
icon = Icons.Outlined.Edit,
tint = MaterialTheme.colors.primary,
onClick = onWrite,
)
if (templates.isNotEmpty()) {
PickerHeading(stringResource(R.string.table_from_presets))
templates.forEach { t ->
CountRow(
title = t.metadata.title.ifBlank { stringResource(R.string.untitled) },
count = counts[t.id] ?: 0,
onChange = { n -> if (n <= 0) counts.remove(t.id) else counts[t.id] = n },
)
}
}
if (held.isNotEmpty()) {
PickerHeading(stringResource(R.string.table_from_held))
held.forEach { n ->
PickRow(
title = n.metadata.title,
subtitle = Verifier.verify(n).stateOrNull
?.let { stringResource(R.string.from_x, Repo.nameOf(it.debtor)) },
picked = n.id in chosen,
onToggle = { if (n.id in chosen) chosen.remove(n.id) else chosen.add(n.id) },
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val ious = held.filter { it.id in chosen }
val mint = templates.flatMap { t -> List(counts[t.id] ?: 0) { t.metadata } }
onPut(ious, mint)
},
) {
// Nothing chosen is a real choice: it takes my side off.
Text(
if (total == 0) stringResource(R.string.table_take_off)
else pluralStringResource(R.plurals.table_put_n, total, total)
)
}
}
}
}
}
}
/** A template, and how many copies of it go on the table. */
@Composable
private fun CountRow(title: String, count: Int, onChange: (Int) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 4.dp),
) {
Icon(
Icons.Outlined.ContentCopy, contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(16.dp))
Text(
title, style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.weight(1f).clickable { onChange(count + 1) },
)
IconButton(enabled = count > 0, onClick = { onChange(count - 1) }) {
Icon(
Icons.Default.Remove, contentDescription = stringResource(R.string.fewer),
tint = if (count > 0) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.3f),
)
}
Text(
count.toString(),
style = MaterialTheme.typography.body1,
color = if (count > 0) MaterialTheme.colors.onSurface
else MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.width(24.dp),
textAlign = TextAlign.Center,
)
IconButton(onClick = { onChange(count + 1) }) {
Icon(
Icons.Default.Add, contentDescription = stringResource(R.string.more),
tint = MaterialTheme.colors.primary
)
}
}
}
/** An OwU you hold: in the bundle, or not. There is only ever one of it. */
@Composable
private fun PickRow(title: String, subtitle: String?, picked: Boolean, onToggle: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.clickable(onClick = onToggle)
.padding(vertical = 8.dp),
) {
Icon(
if (picked) Icons.Default.CheckCircle else Icons.AutoMirrored.Filled.Send,
contentDescription = null,
tint = if (picked) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(16.dp))
Column {
Text(title, style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onSurface)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
}
}
}
/** A row that does one thing when tapped. */
@Composable
private fun PickerRow(
title: String,
icon: ImageVector,
tint: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(16.dp))
Text(title, style = MaterialTheme.typography.body1, color = tint)
}
}
@Composable
private fun PickerHeading(text: String) {
Text(
text,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
/** The table between two people: what each put down, and who said yes.
* Tapping my side opens the picker; the button is my yes. */
@Composable
fun TradeTable(
table: PeerEngine.Table,
theirName: String,
theirKey: String?,
enabled: Boolean,
onPut: () -> Unit,
onAccept: () -> Unit,
modifier: Modifier = Modifier,
/** My side is a promise of theirs: saying yes hands it home and closes it. */
redeeming: Boolean = false,
/** Ids of ious on the table whose place we are not at, to be shown in red. */
outOfPlace: Set<String> = emptySet(),
) {
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(R.string.table_title),
style = MaterialTheme.typography.subtitle2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.weight(1f),
)
TextButton(enabled = enabled, onClick = onPut) {
Text(stringResource(if (table.mine.isEmpty()) R.string.table_put else R.string.table_change))
}
}
TableSide(
who = stringResource(R.string.table_you),
ious = table.mine,
accepted = table.accepted,
owner = Repo.me,
recipient = theirKey,
outOfPlace = outOfPlace,
modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { onPut() },
)
Divider()
TableSide(
who = theirName,
ious = table.theirs,
accepted = table.theyAccepted,
owner = theirKey,
recipient = Repo.me,
outOfPlace = outOfPlace,
modifier = Modifier.fillMaxWidth(),
)
if (table.conflict) {
Text(
stringResource(R.string.table_conflict),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(top = 4.dp),
)
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Button(enabled = enabled && !table.empty && !table.accepted && !table.conflict, onClick = onAccept) {
Text(stringResource(if (redeeming) R.string.action_redeem else R.string.action_confirm))
}
}
if (table.accepted && !table.theyAccepted) {
Text(
stringResource(R.string.table_waiting_for, theirName), style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
private fun TableSide(
who: String,
ious: List<Iou>,
accepted: Boolean,
/** Who put this side down: their own promise, or one they pass on. */
owner: String?,
/** Who this side is going to, which is how an OwU coming home is known. */
recipient: String?,
outOfPlace: Set<String>,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.padding(vertical = 8.dp)) {
Text(
who, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.width(72.dp)
)
Column(modifier = Modifier.weight(1f)) {
if (ious.isEmpty()) {
Text(
stringResource(R.string.table_nothing),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
)
}
// Five of the same promise is one line saying five, not five lines.
ious.groupBy { it.metadata }.forEach { (metadata, same) ->
val first = same.first()
Text(
if (same.size > 1) "${same.size} × ${metadata.title}" else metadata.title,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
)
// Who has to make good on it: the thing worth knowing.
Verifier.verify(first).stateOrNull?.let { state ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
when (state.debtor) {
// Whoever put it down owes it themselves, which
// on my own side is me.
owner -> stringResource(
if (owner == Repo.me) R.string.own_promise_yours else R.string.own_promise
)
Repo.me -> stringResource(R.string.own_promise_mine)
else -> stringResource(R.string.owed_by_x, Repo.nameOf(state.debtor))
},
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
if (state.debtor != Repo.me) KnownMark(state.debtor)
}
// When and where: what the one who owes it is being asked
// about. Going home to its debtor is a redemption, so the
// window counts.
MetaGates(
metadata = metadata,
homecoming = state.debtor == recipient,
outOfPlace = same.any { it.id in outOfPlace },
)
}
}
}
if (accepted) Icon(
Icons.Default.Check, contentDescription = stringResource(R.string.action_accept),
tint = MaterialTheme.colors.primary
)
}
}
/** A QR code drawn straight onto a canvas; always black on white so any scanner reads it. */
@Composable
fun QrCode(text: String, modifier: Modifier = Modifier) {
val matrix = remember(text) {
QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, mapOf(EncodeHintType.MARGIN to 0))
}
Canvas(modifier.background(Color.White).padding(12.dp)) {
val cell = minOf(size.width / matrix.width, size.height / matrix.height)
val ox = (size.width - cell * matrix.width) / 2
val oy = (size.height - cell * matrix.height) / 2
for (y in 0 until matrix.height) for (x in 0 until matrix.width) {
if (matrix[x, y]) drawRect(
Color.Black,
Offset(ox + x * cell, oy + y * cell),
Size(cell + 0.5f, cell + 0.5f)
)
}
}
}
/**
* An optional instant, picked with the platform's date and time dialogs.
* Read-only field, a tap opens the pickers, the trailing button clears it.
*/
@Composable
fun DateTimeField(
label: String,
value: Long?,
onChange: (Long?) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
fun pick() {
val zone = ZoneId.systemDefault()
val start = value?.let { Instant.ofEpochSecond(it).atZone(zone) }
?: ZonedDateTime.now(zone).plusHours(1).withMinute(0)
DatePickerDialog(context, { _, y, m, d ->
TimePickerDialog(context, { _, h, min ->
onChange(ZonedDateTime.of(y, m + 1, d, h, min, 0, 0, zone).toEpochSecond())
}, start.hour, start.minute, DateFormat.is24HourFormat(context)).show()
}, start.year, start.monthValue - 1, start.dayOfMonth).show()
}
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
OutlinedTextField(
value = value?.let { formatTime(it) } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
)
// The field swallows taps; a transparent layer over it opens the pickers.
Box(Modifier.matchParentSize().clickable { pick() })
}
IconButton(onClick = { onChange(null) }, enabled = value != null) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
}
/**
* The strings an OwU carries about when and where it may be redeemed, under
* whatever it is attached to. They are never a bar - a late OwU can still be
* handed back - so they are here to be read, by the holder deciding to ask
* and by the one who wrote it deciding to say yes.
*/
@Composable
fun MetaGates(
metadata: Metadata,
/** This OwU is going home, so a window that is merely not open yet counts against it. */
homecoming: Boolean = false,
/** We are not where it says, as far as this phone can tell. */
outOfPlace: Boolean = false,
) {
if (metadata.hasWindow) {
val gate = metadata.timeGate()
// Expired is red wherever it is shown; too early only matters when
// somebody is trying to redeem it now.
val wrong = gate is TimeGate.Expired || (homecoming && gate is TimeGate.NotYet)
GateLine(
icon = Icons.Default.Schedule,
text = when (gate) {
is TimeGate.NotYet -> stringResource(R.string.window_from, formatTime(gate.opensAt))
is TimeGate.Expired -> stringResource(R.string.window_expired)
TimeGate.Open -> metadata.notAfter?.let { stringResource(R.string.window_until, formatTime(it)) } ?: ""
},
wrong = wrong,
)
}
metadata.geoloc?.let { geo ->
GateLine(
icon = Icons.Default.Place,
text = geo.label ?: "%.4f, %.4f".format(geo.latitude, geo.longitude),
wrong = outOfPlace,
)
}
}
@Composable
private fun GateLine(icon: ImageVector, text: String, wrong: Boolean) {
val color = if (wrong) MaterialTheme.colors.error else MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = color)
Spacer(Modifier.width(2.dp))
Text(text, style = MaterialTheme.typography.caption, color = color)
}
}
/**
* Go back, unless there is nothing to go back to. `popBackStack()` on the last
* entry leaves the NavHost with nothing to draw - a blank screen - which is
* what two quick taps on a back arrow, or one racing the system gesture, do.
*/
fun NavHostController.up() {
if (previousBackStackEntry != null) popBackStack()
}
/**
* A bundle in one line - "2 × 1 Beer, 1 Coffee" - rather than the same title
* over and over.
*/
fun bundleLine(ious: List<Iou>): String =
ious.groupingBy { it.metadata.title }.eachCount().entries
.joinToString(", ") { (title, n) -> if (n > 1) "$n × $title" else title }
@@ -0,0 +1,78 @@
package net.helcel.owu.activity.sub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.owu.BuildConfig
import net.helcel.owu.activity.OwuFace
import net.helcel.owu.R
@Preview
@Composable
fun AboutScreen(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(top = 20.dp).background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Masked like a launcher icon, because that is what it is a preview of.
OwuFace(modifier = Modifier.size(200.dp).clip(CircleShape))
Text(
text = BuildConfig.PUBLIC_NAME,
fontSize = 30.sp,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
)
Text(
text = BuildConfig.VERSION_NAME,
fontSize = 25.sp,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
)
Text(
text = stringResource(R.string.app_is_foss),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
)
val uriHandler = LocalUriHandler.current
val uri = stringResource(R.string.app_repo_uri)
Text(
text = stringResource(id = R.string.app_repo, uri),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier
.clickable {
uriHandler.openUri(uri)
}
.padding(vertical = 15.dp, horizontal = 10.dp)
)
}
}
@@ -0,0 +1,55 @@
package net.helcel.owu.ble
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import net.helcel.owu.crypto.Hash
import java.util.Base64
object Ble {
/**
* Manufacturer id our packets ride under. 0xFFFF is the id the SIG
* reserves for internal use and testing; our service UUID is what keeps
* other testers' packets out.
*/
const val MANUFACTURER = 0xFFFF
/** Eight bytes of a key's SHA-256: what a device is known as on the air. */
fun beacon(publicKey: String): ByteArray =
Hash.sha256(Base64.getDecoder().decode(publicKey)).copyOf(8)
fun beaconHex(publicKey: String): String = hex(beacon(publicKey))
/** A beacon as text, for keying maps by device. */
fun hex(beacon: ByteArray): String = Hash.hex(beacon)
}
object BlePermissions {
/** Runtime permissions Bluetooth needs on this OS version. */
fun required(): List<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
) else listOf(Manifest.permission.ACCESS_FINE_LOCATION)
fun granted(context: Context): Boolean = required().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
fun adapter(context: Context): BluetoothAdapter? =
context.getSystemService(BluetoothManager::class.java)?.adapter
}
/** What the link hands up: somebody is there, or somebody said something. */
sealed class Heard {
/** A device's advertisement, and how strongly it came in. */
class Presence(val beacon: ByteArray, val rssi: Int) : Heard()
/** One whole message, and the beacon (or its leading bytes) it came from. */
class Message(val from: ByteArray, val bytes: ByteArray) : Heard()
}
@@ -0,0 +1,60 @@
package net.helcel.owu.ble
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
/**
* Messages over a link that delivers bytes in order, in any sizes. A frame is
* `length (4) + sender beacon (8) + payload`, the length covering both.
*
* The beacon rides every frame: a connection says which *device* is talking,
* not which identity, and the side that accepted it may never have heard that
* device advertise.
*/
class Frames(private val limit: Int = MAX_MESSAGE) {
private val buffer = ByteArrayOutputStream()
/** One message, ready to be written in whatever chunks the link takes. */
fun frame(from: ByteArray, payload: ByteArray): ByteArray =
ByteBuffer.allocate(4 + BEACON + payload.size)
.putInt(BEACON + payload.size)
.put(from, 0, BEACON)
.put(payload)
.array()
/** Feeds what arrived, returning whatever is now whole as (beacon, payload).
* A frame longer than [limit] is not ours, so the stream is dropped. */
fun feed(bytes: ByteArray): List<Pair<ByteArray, ByteArray>> {
buffer.write(bytes)
val out = mutableListOf<Pair<ByteArray, ByteArray>>()
var have = buffer.toByteArray()
while (have.size >= 4) {
val length = ByteBuffer.wrap(have, 0, 4).int
if (length < BEACON || length > limit) {
buffer.reset()
return out
}
if (have.size < 4 + length) break
out += have.copyOfRange(4, 4 + BEACON) to have.copyOfRange(4 + BEACON, 4 + length)
have = have.copyOfRange(4 + length, have.size)
}
buffer.reset()
buffer.write(have)
return out
}
/** Forgets a half-received frame, as when a connection drops. */
fun clear() = buffer.reset()
/** Cuts [frame] into pieces of at most [size] bytes, as one link write each. */
fun chunks(frame: ByteArray, size: Int): List<ByteArray> {
require(size > 0) { "chunk size" }
return (frame.indices step size).map { frame.copyOfRange(it, minOf(it + size, frame.size)) }
}
companion object {
const val BEACON = 8
/** Nothing this app sends comes near this; anything longer is a broken stream. */
const val MAX_MESSAGE = 1 shl 18
}
}
@@ -0,0 +1,446 @@
package net.helcel.owu.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothGattServerCallback
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* An ordinary BLE connection. Every device advertises a connectable packet and
* scans for the same; a connection opens only when there is something to say.
* [Frames] framing, MTU-sized: the dialler writes the characteristic, the
* answerer notifies on it, and both run a server so either can dial.
*
* A second carrier once put messages into Bluetooth 5 extended advertisements.
* Dropped: two phones both reporting extended support could not hear each other.
*/
@SuppressLint("MissingPermission") // the manager checks BlePermissions before start()
class Link(
/** The application context: a GATT server outlives any one screen. */
private val context: Context,
private val adapter: BluetoothAdapter,
private val me: ByteArray,
/** Advertise/scan failed after start(). Binder thread. */
private val onBroken: (String) -> Unit = {},
) {
private val _presence = MutableSharedFlow<Heard.Presence>(extraBufferCapacity = 64)
/** Devices heard advertising, and what they can do. */
val presence: SharedFlow<Heard.Presence> = _presence
private val _messages = MutableSharedFlow<Heard.Message>(extraBufferCapacity = 64)
val messages: SharedFlow<Heard.Message> = _messages
/** One open connection, either one we made or one we accepted. */
private inner class Channel(
/** Empty until their first frame names them, on a connection we accepted. */
var beacon: ByteArray,
val device: BluetoothDevice,
/** Set when we dialled; null when they did. */
val gatt: BluetoothGatt?,
) {
val frames = Frames()
val write = Mutex()
var mtu: Int = DEFAULT_MTU
/** Completes when the connection is usable both ways. */
val ready = CompletableDeferred<Boolean>()
/** Completes when the last write was acknowledged. */
var sent: CompletableDeferred<Boolean>? = null
}
private val channels = ConcurrentHashMap<String, Channel>() // by device address
private val addresses = ConcurrentHashMap<String, String>() // beacon hex -> device address
private var server: BluetoothGattServer? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiser: android.bluetooth.le.BluetoothLeAdvertiser? = null
private var scanner: android.bluetooth.le.BluetoothLeScanner? = null
fun start(): String? {
val manager = context.getSystemService(BluetoothManager::class.java) ?: return "No Bluetooth service"
val chr = BluetoothGattCharacteristic(
DATA,
BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_WRITE,
).apply {
addDescriptor(
BluetoothGattDescriptor(
CCCD,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE,
)
)
}
val service = BluetoothGattService(SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY).apply {
addCharacteristic(chr)
}
val gattServer = manager.openGattServer(context, serverCallback) ?: return "Cannot open a GATT server"
gattServer.addService(service)
server = gattServer
characteristic = chr
val adv = adapter.bluetoothLeAdvertiser ?: return "This device cannot advertise"
advertiser = adv
// Connectable, legacy, 31 bytes: the service to be found by and our
// beacon to be known by.
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(false)
.addServiceUuid(ParcelUuid(SERVICE))
.addManufacturerData(Ble.MANUFACTURER, me)
.build()
runCatching { adv.startAdvertising(settings, data, advertiseCallback) }
.onFailure { return "Could not advertise: ${it.message}" }
val scan = adapter.bluetoothLeScanner ?: return "This device cannot scan"
scanner = scan
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE)).build()
val scanSettings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
runCatching { scan.startScan(listOf(filter), scanSettings, scanCallback) }
.onFailure { return "Could not scan: ${it.message}" }
return null
}
fun stop() {
runCatching { scanner?.stopScan(scanCallback) }
runCatching { advertiser?.stopAdvertising(advertiseCallback) }
channels.values.toList().forEach { drop(it) }
runCatching { server?.close() }
server = null
advertiser = null
scanner = null
addresses.clear()
}
/**
* Opens a connection to [beacon], or returns true if one is already up.
* Only the side that calls this dials; the other simply accepts.
*/
suspend fun connect(beacon: ByteArray): Boolean {
channels.values.firstOrNull { it.beacon.contentEquals(beacon) }?.let { open ->
return withTimeoutOrNull(CONNECT_MS) { open.ready.await() } ?: false
}
val address = addresses[Ble.hex(beacon)] ?: return false
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
val gatt = device.connectGatt(context, false, clientCallback, BluetoothDevice.TRANSPORT_LE) ?: return false
val channel = Channel(beacon, device, gatt)
channels[address] = channel
val ok = withTimeoutOrNull(CONNECT_MS) { channel.ready.await() } ?: false
if (!ok) drop(channel)
return ok
}
/** Hangs up on [beacon]; harmless if there is nothing to hang up on. */
fun disconnect(beacon: ByteArray) {
channels.values.filter { it.beacon.contentEquals(beacon) }.forEach { drop(it) }
}
private fun drop(channel: Channel) {
channels.remove(channel.device.address)
channel.frames.clear()
if (!channel.ready.isCompleted) channel.ready.complete(false)
channel.sent?.takeIf { !it.isCompleted }?.complete(false)
if (channel.gatt != null) {
runCatching { channel.gatt.disconnect() }
runCatching { channel.gatt.close() }
} else {
runCatching { server?.cancelConnection(channel.device) }
}
}
/** Sends one message to [to]; true once the link took it. With [dial],
* connects first if there is none - what a person's action deserves and
* a HELLO said into the dark does not. */
suspend fun send(to: ByteArray, message: ByteArray, dial: Boolean = false): Boolean {
val channel = channelTo(to) ?: (if (dial && connect(to)) channelTo(to) else null)
if (channel == null) {
Log.w(
TAG,
"send ${message.size}B to ${Ble.hex(to)}: no channel, have ${channels.values.map { Ble.hex(it.beacon) }}"
)
return false
}
if (!channel.ready.isCompleted && withTimeoutOrNull(CONNECT_MS) { channel.ready.await() } != true) {
Log.w(TAG, "send ${message.size}B to ${Ble.hex(to)}: channel never became ready")
drop(channel)
return false
}
val frame = channel.frames.frame(me, message)
// One write is an attribute value, which is 512 bytes however large
// the MTU says a packet may be.
val room = minOf(channel.mtu - ATT_OVERHEAD, MAX_ATTRIBUTE)
val kind = if (channel.gatt != null) "write" else "notify"
return channel.write.withLock {
var n = 0
for (chunk in channel.frames.chunks(frame, room)) {
n++
if (!writeChunk(channel, chunk)) {
// A refused or unacknowledged write means this connection
// is finished, whatever the stack thinks. Left in place it
// is found by every later attempt, believed ready, and
// fails the same way for ever. Dropped, the next one dials.
Log.w(
TAG,
"send ${message.size}B to ${Ble.hex(to)}: $kind chunk $n of ${chunk.size}B failed, mtu ${channel.mtu}"
)
drop(channel)
return@withLock false
}
}
true
}
}
/**
* The connection to [to], if we have one. One we accepted is nameless
* until their first frame, and must *not* be guessed at: guessing wrong
* hands somebody else's promise to whoever is connected. Dialling our own
* costs a second and is never wrong.
*/
private fun channelTo(to: ByteArray): Channel? =
channels.values.firstOrNull { it.beacon.contentEquals(to) }
private suspend fun writeChunk(channel: Channel, chunk: ByteArray): Boolean {
val done = CompletableDeferred<Boolean>()
channel.sent = done
val chr = characteristic ?: return false
val ok = runCatching { putOnLink(channel, chr, chunk) }.getOrElse {
Log.w(TAG, "write refused", it)
false
}
if (!ok) return false
return withTimeoutOrNull(WRITE_MS) { done.await() } ?: false
}
/** The one write, whichever end of the connection we are. */
private fun putOnLink(channel: Channel, chr: BluetoothGattCharacteristic, chunk: ByteArray): Boolean {
return if (channel.gatt != null) {
val client = channel.gatt.getService(SERVICE)?.getCharacteristic(DATA) ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
channel.gatt.writeCharacteristic(client, chunk, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
client.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
client.value = chunk
channel.gatt.writeCharacteristic(client)
}
}
} else {
val srv = server ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
srv.notifyCharacteristicChanged(channel.device, chr, false, chunk) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
chr.value = chunk
srv.notifyCharacteristicChanged(channel.device, chr, false)
}
}
}
}
// --- what arrives ------------------------------------------------------
private fun received(channel: Channel, bytes: ByteArray) {
for ((from, payload) in channel.frames.feed(bytes)) {
if (channel.beacon.all { it == ZERO }) channel.beacon = from
addresses[Ble.hex(from)] = channel.device.address
_presence.tryEmit(Heard.Presence(from, 0))
_messages.tryEmit(Heard.Message(from, payload))
}
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val data = result.scanRecord?.getManufacturerSpecificData(Ble.MANUFACTURER) ?: return
if (data.size < 8) return
val beacon = data.copyOf(8)
if (beacon.contentEquals(me)) return
addresses[Ble.hex(beacon)] = result.device.address
_presence.tryEmit(Heard.Presence(beacon, result.rssi))
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "legacy scan failed: $errorCode")
if (errorCode != SCAN_FAILED_ALREADY_STARTED) onBroken("Could not scan ($errorCode)")
}
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartFailure(errorCode: Int) {
Log.w(TAG, "legacy advertising failed: $errorCode")
if (errorCode != ADVERTISE_FAILED_ALREADY_STARTED) onBroken("Could not advertise ($errorCode)")
}
}
private val clientCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val channel = channels[gatt.device.address] ?: return
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.requestMtu(WANTED_MTU)
} else {
drop(channel)
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
channels[gatt.device.address]?.mtu = if (status == BluetoothGatt.GATT_SUCCESS) mtu else DEFAULT_MTU
gatt.discoverServices()
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val channel = channels[gatt.device.address] ?: return
val chr = gatt.getService(SERVICE)?.getCharacteristic(DATA)
if (chr == null) {
drop(channel)
return
}
gatt.setCharacteristicNotification(chr, true)
val cccd = chr.getDescriptor(CCCD)
if (cccd == null) {
channel.ready.complete(true)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
run {
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
channels[gatt.device.address]?.ready?.complete(true)
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, chr: BluetoothGattCharacteristic, status: Int) {
channels[gatt.device.address]?.sent?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(gatt: BluetoothGatt, chr: BluetoothGattCharacteristic) {
channels[gatt.device.address]?.let { received(it, chr.value ?: return) }
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, chr: BluetoothGattCharacteristic, value: ByteArray) {
channels[gatt.device.address]?.let { received(it, value) }
}
}
private val serverCallback = object : BluetoothGattServerCallback() {
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
// Their beacon arrives with their first frame; until then the
// channel is known only by address.
channels.getOrPut(device.address) {
Channel(
ByteArray(8),
device,
null
).also { it.ready.complete(true) }
}
} else {
channels[device.address]?.let { drop(it) }
}
}
override fun onMtuChanged(device: BluetoothDevice, mtu: Int) {
channels[device.address]?.mtu = mtu
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice,
requestId: Int,
chr: BluetoothGattCharacteristic,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray,
) {
val channel = channels.getOrPut(device.address) {
Channel(ByteArray(8), device, null).also { it.ready.complete(true) }
}
if (responseNeeded) {
server?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
received(channel, value)
}
override fun onDescriptorWriteRequest(
device: BluetoothDevice,
requestId: Int,
descriptor: BluetoothGattDescriptor,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray,
) {
if (responseNeeded) {
server?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
}
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
channels[device.address]?.sent?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
}
companion object {
private const val TAG = "OwuLink"
private const val ZERO: Byte = 0
/** 16-bit alias f077, so the advertisement stays inside 31 bytes. */
val SERVICE: UUID = UUID.fromString("0000f077-0000-1000-8000-00805f9b34fb")
val DATA: UUID = UUID.fromString("0000f078-0000-1000-8000-00805f9b34fb")
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEFAULT_MTU = 23
private const val WANTED_MTU = 517
/** ATT write header. */
private const val ATT_OVERHEAD = 3
/** An attribute value is 512 bytes at most, whatever the MTU allows. */
private const val MAX_ATTRIBUTE = 512
private const val CONNECT_MS = 15_000L
private const val WRITE_MS = 5_000L
}
}
@@ -0,0 +1,70 @@
package net.helcel.owu.crypto
/**
* Deterministic JSON, the RFC 8785 subset the ledger needs: keys sorted by
* UTF-16 code unit, strings, whole numbers, booleans, lists. Everything signed
* or hashed comes through here, never the transport encoder, so the wire
* format can change without breaking a signature.
*
* Nulls are dropped, so a field added later does not alter older bytes. Floats
* are rejected: one double has no single textual form across platforms, which
* is why coordinates are micro-degrees.
*/
object Canonical {
fun bytes(value: Any?): ByteArray = encode(value).toByteArray(Charsets.UTF_8)
fun encode(value: Any?): String = StringBuilder().also { write(it, value) }.toString()
private fun write(sb: StringBuilder, value: Any?) {
when (value) {
null -> sb.append("null")
is Boolean -> sb.append(value)
is Int, is Long, is Short, is Byte -> sb.append(value)
is String -> writeString(sb, value)
is Map<*, *> -> {
val keys = value.keys.map {
it as? String ?: throw IllegalArgumentException("non-string key: $it")
}.sorted()
sb.append('{')
var first = true
for (k in keys) {
val v = value[k] ?: continue
if (!first) sb.append(',')
first = false
writeString(sb, k)
sb.append(':')
write(sb, v)
}
sb.append('}')
}
is List<*> -> {
sb.append('[')
value.forEachIndexed { i, v ->
if (i > 0) sb.append(',')
write(sb, v)
}
sb.append(']')
}
else -> throw IllegalArgumentException("cannot canonicalise ${value::class.simpleName}")
}
}
private fun writeString(sb: StringBuilder, s: String) {
sb.append('"')
for (c in s) {
when {
c == '"' -> sb.append("\\\"")
c == '\\' -> sb.append("\\\\")
c == '\b' -> sb.append("\\b")
c == '\u000C' -> sb.append("\\f")
c == '\n' -> sb.append("\\n")
c == '\r' -> sb.append("\\r")
c == '\t' -> sb.append("\\t")
c < ' ' -> sb.append("\\u%04x".format(c.code))
else -> sb.append(c)
}
}
sb.append('"')
}
}
@@ -0,0 +1,16 @@
package net.helcel.owu.crypto
import java.security.MessageDigest
object Hash {
/** The parent hash of a genesis block: nothing came before it. */
const val ZERO = "0000000000000000000000000000000000000000000000000000000000000000"
fun sha256(bytes: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes)
fun sha256Hex(bytes: ByteArray): String = hex(sha256(bytes))
fun hex(bytes: ByteArray): String = buildString(bytes.size * 2) {
for (b in bytes) append("%02x".format(b))
}
}
@@ -0,0 +1,140 @@
package net.helcel.owu.crypto
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.io.File
import java.nio.ByteBuffer
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Who this phone is: a P-256 pair in the app's storage, sealed with an AES key
* in the Android Keystore, so a copy of the file is worth nothing elsewhere.
*
* Deliberately *not* a Keystore signing key, which can never be read back: the
* identity would die with the phone and every OwU anyone holds from you would
* be unredeemable. A backup must outlive the phone, so [export] hands the key
* over.
*/
class Identity private constructor(private val pair: KeyPair) : Signer {
override val publicKey: String = Keys.encode(pair.public)
override fun sign(data: ByteArray): String = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(data)
Keys.encodeSignature(sign())
}
/** Both halves, for a backup the user has asked for: PKCS#8 and X.509 DER. */
fun export(): Pair<ByteArray, ByteArray> = pair.private.encoded to pair.public.encoded
companion object {
private const val FILE = "identity.key"
private const val ALIAS = "owu_identity_wrap"
private const val PROVIDER = "AndroidKeyStore"
private const val TAG_BITS = 128
private const val NONCE = 12
fun load(context: Context): Identity {
val file = File(context.filesDir, FILE)
if (!file.exists()) {
val fresh = KeyPairGenerator.getInstance("EC")
.apply { initialize(ECGenParameterSpec("secp256r1")) }
.generateKeyPair()
write(file, fresh.private.encoded, fresh.public.encoded)
return Identity(fresh)
}
val (priv, pub) = read(file)
return Identity(pairOf(priv, pub))
}
/**
* Takes on the identity in a backup, replacing whatever this phone
* had. The caller asks first: the old identity is gone afterwards, and
* with it the ability to close promises made under it.
*/
fun replace(context: Context, pkcs8: ByteArray, x509: ByteArray): Identity {
val pair = pairOf(pkcs8, x509) // rejects a mismatched pair below
write(File(context.filesDir, FILE), pkcs8, x509)
return Identity(pair)
}
/**
* The pair those two encodings describe, refusing them unless the
* private half really does sign for the public one - a backup with
* mismatched halves would be an identity that cannot sign.
*/
fun pairOf(pkcs8: ByteArray, x509: ByteArray): KeyPair {
val factory = KeyFactory.getInstance("EC")
val pair = KeyPair(
factory.generatePublic(X509EncodedKeySpec(x509)),
factory.generatePrivate(PKCS8EncodedKeySpec(pkcs8)),
)
val proof = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(PROOF)
sign()
}
val good = Signature.getInstance(Keys.ALGORITHM).run {
initVerify(pair.public)
update(PROOF)
verify(proof)
}
require(good) { "the two halves of that key do not belong together" }
return pair
}
private val PROOF = "owu-identity-check".toByteArray(Charsets.UTF_8)
/** The file holds both encodings, length-prefixed, under one seal. */
private fun read(file: File): Pair<ByteArray, ByteArray> {
val bytes = file.readBytes()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(TAG_BITS, bytes.copyOf(NONCE)))
val plain = ByteBuffer.wrap(cipher.doFinal(bytes, NONCE, bytes.size - NONCE))
val priv = ByteArray(plain.int).also { plain.get(it) }
val pub = ByteArray(plain.remaining()).also { plain.get(it) }
return priv to pub
}
private fun write(file: File, pkcs8: ByteArray, x509: ByteArray) {
val plain = ByteBuffer.allocate(4 + pkcs8.size + x509.size)
.putInt(pkcs8.size).put(pkcs8).put(x509).array()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
val sealed = cipher.iv + cipher.doFinal(plain)
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.writeBytes(sealed)
if (!tmp.renameTo(file)) {
file.delete()
tmp.renameTo(file)
}
}
/** The AES key that seals the file, made once and kept in the Keystore. */
private fun wrapKey(): SecretKey {
val store = KeyStore.getInstance(PROVIDER).apply { load(null) }
(store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER)
generator.init(
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}
}
}
@@ -0,0 +1,120 @@
package net.helcel.owu.crypto
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
/**
* Public keys travel as base64 of their X.509 SubjectPublicKeyInfo DER, the
* form `PublicKey.getEncoded()` gives for an EC key on every platform. That
* string is a user's identity: it is what IOUs are issued and transferred to.
*/
object Keys {
const val ALGORITHM = "SHA256withECDSA"
fun encode(key: PublicKey): String = Base64.getEncoder().encodeToString(key.encoded)
fun decode(encoded: String): PublicKey? = try {
KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(encoded)))
} catch (e: Exception) {
null
}
/**
* True if [signature] (base64 DER) verifies [data] under [publicKey] **and**
* is in the one encoding we accept.
*
* ECDSA admits two signatures per key and message - (r, s) and (r, n - s) -
* and DER admits padding. Either lets anyone who has merely *seen* an OwU
* rewrite its head signature: still valid, different block hash, so the two
* copies look like a double-spend by their holder. Low-s minimal DER only,
* which makes a fork mean what it says: someone signed twice.
*/
fun verify(publicKey: String, data: ByteArray, signature: String): Boolean {
val key = decode(publicKey) ?: return false
val der = try {
Base64.getDecoder().decode(signature)
} catch (e: Exception) {
return false
}
if (!isCanonical(der)) return false
return try {
Signature.getInstance(ALGORITHM).run {
initVerify(key)
update(data)
verify(der)
}
} catch (e: Exception) {
false
}
}
/** A signature as it must be stored: minimal DER, low s, base64. */
fun encodeSignature(der: ByteArray): String =
Base64.getEncoder().encodeToString(canonical(der))
/** The same signature in the one accepted encoding. */
fun canonical(der: ByteArray): ByteArray {
val (r, s) = parse(der) ?: return der
return encodePair(r, if (s > HALF_ORDER) ORDER.subtract(s) else s)
}
fun isCanonical(der: ByteArray): Boolean {
val (r, s) = parse(der) ?: return false
return s <= HALF_ORDER && der.contentEquals(encodePair(r, s))
}
/** (r, s) of a DER SEQUENCE of two INTEGERs, or null if it is not one. */
private fun parse(der: ByteArray): Pair<BigInteger, BigInteger>? {
// P-256 signatures are far below 128 bytes, so only short-form
// lengths are ever legitimate here.
if (der.size < 8 || der.size > 72 || der[0] != 0x30.toByte()) return null
if ((der[1].toInt() and 0xff) != der.size - 2) return null
fun integer(at: Int): Pair<BigInteger, Int>? {
if (at + 1 >= der.size || der[at] != 0x02.toByte()) return null
val length = der[at + 1].toInt() and 0xff
if (length == 0 || at + 2 + length > der.size) return null
val bytes = der.copyOfRange(at + 2, at + 2 + length)
// Minimal encoding: no leading zero unless the next byte would
// read as negative, and never negative itself.
if (bytes[0].toInt() and 0x80 != 0) return null
if (bytes.size > 1 && bytes[0] == 0.toByte() && bytes[1].toInt() and 0x80 == 0) return null
return BigInteger(1, bytes) to at + 2 + length
}
val (r, next) = integer(2) ?: return null
val (s, end) = integer(next) ?: return null
if (end != der.size) return null
if (r.signum() <= 0 || s.signum() <= 0 || r >= ORDER || s >= ORDER) return null
return r to s
}
private fun encodePair(r: BigInteger, s: BigInteger): ByteArray {
fun integer(v: BigInteger): ByteArray {
val bytes = v.toByteArray() // already minimal and non-negative
return byteArrayOf(0x02, bytes.size.toByte()) + bytes
}
val body = integer(r) + integer(s)
return byteArrayOf(0x30, body.size.toByte()) + body
}
/** The order of the P-256 group, and the line between low and high s. */
private val ORDER = BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16)
private val HALF_ORDER = ORDER.shiftRight(1)
/**
* A short, human-comparable handle for a key: the first 64 bits of the
* SHA-256 of its DER, as four groups of hex. For display only.
*/
fun fingerprint(publicKey: String): String {
val der = try {
Base64.getDecoder().decode(publicKey)
} catch (e: Exception) {
publicKey.toByteArray()
}
return Hash.sha256Hex(der).take(16).chunked(4).joinToString(" ")
}
}
@@ -0,0 +1,13 @@
package net.helcel.owu.crypto
/**
* Something that can sign on behalf of one identity. On a device this is the
* Keystore-held key; in tests it is an in-memory pair.
*/
interface Signer {
/** The identity this signer speaks for, as [Keys.encode] gives it. */
val publicKey: String
/** ECDSA over SHA-256 of [data], returned as base64 DER. */
fun sign(data: ByteArray): String
}
@@ -0,0 +1,122 @@
package net.helcel.owu.demo
import net.helcel.owu.crypto.Keys
import net.helcel.owu.crypto.Signer
import net.helcel.owu.ledger.GeoLoc
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.store.Contact
import net.helcel.owu.store.IouStore
import net.helcel.owu.store.Template
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.util.UUID
/**
* A ledger worth looking at, debug only: its one caller sits behind
* `BuildConfig.DEBUG`, so R8 drops all of this from a release.
*
* Signed for real by throwaway keys, so these verify, trade and redeem like
* any other, and cover the states a screen can be in: held, owed, arrived
* through a third party, place-bound, not yet valid, long expired, over.
*/
object Demo {
/** A key that lives only as long as this demo needs it. */
private class SoftSigner : Signer {
private val pair = KeyPairGenerator.getInstance("EC")
.apply { initialize(ECGenParameterSpec("secp256r1")) }
.generateKeyPair()
override val publicKey: String = Keys.encode(pair.public)
override fun sign(data: ByteArray): String = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(data)
Keys.encodeSignature(sign())
}
}
private const val HOUR = 3600L
private const val DAY = 24 * HOUR
/** True when there is nothing here yet, and so nothing to disturb. */
fun wanted(store: IouStore): Boolean =
store.ious.value.isEmpty() && store.templates.value.isEmpty()
fun fill(store: IouStore, me: Signer) {
val now = Ledger.now()
val dana = SoftSigner()
val eli = SoftSigner()
val mo = SoftSigner()
val kim = SoftSigner()
// Two people you have named, one you have only met - so an OwU owed by
// Mo shows without the tick - and one who only ever said hello.
store.putContact(Contact(dana.publicKey, "Dana"))
store.putContact(Contact(eli.publicKey, "Eli"))
store.putMet(mo.publicKey, "Mo")
store.putMet(kim.publicKey, "Kim")
// Promises you keep ready.
listOf(
Metadata("1 Beer"),
Metadata("1 Coffee", geoloc = GeoLoc.of(46.5197, 6.6323, 150, "Café du Lac")),
Metadata("1 Ride to the airport", "Any terminal, any hour. Wake me if you must."),
Metadata("1 Heavy Hug", notAfter = now + 30 * DAY),
).forEachIndexed { i, metadata ->
store.putTemplate(Template(UUID.randomUUID().toString(), metadata, now - i * HOUR))
}
// OwUs you hold, from people who owe you.
store.put(
handedOver(
dana,
me.publicKey,
Metadata("1 Coffee", geoloc = GeoLoc.of(46.5197, 6.6323, 150, "Café du Lac")),
now - 30 * HOUR
)
)
store.put(handedOver(eli, me.publicKey, Metadata("1 Ride to the airport"), now - 26 * HOUR))
store.put(
handedOver(
dana,
me.publicKey,
Metadata("1 Lunch", "Somewhere with a terrace.", notAfter = now - DAY),
now - 20 * DAY
)
)
store.put(
handedOver(
eli,
me.publicKey,
Metadata("1 Concert ticket", notBefore = now + 2 * DAY),
now - 8 * HOUR
)
)
// One that reached you through somebody else: Mo promised it, Dana
// passed it on. Mo is no contact of yours, so it reads as a stranger's.
val third = handedOver(mo, dana.publicKey, Metadata("1 Cinema ticket"), now - 12 * HOUR)
store.put(Ledger.transfer(third, dana, me.publicKey, now - 6 * HOUR))
// OwUs you owe.
store.put(handedOver(me, dana.publicKey, Metadata("1 Massage"), now - 18 * HOUR))
store.put(handedOver(me, eli.publicKey, Metadata("2 Beers"), now - 16 * HOUR))
// And one that is over: handed home to Dana, who closed it.
val tea = handedOver(dana, me.publicKey, Metadata("1 Tea"), now - 5 * DAY)
val home = Ledger.transfer(tea, me, dana.publicKey, now - 4 * DAY)
store.put(Ledger.redeem(home, dana, now - 4 * DAY + HOUR))
}
/**
* A promise written by [debtor] and handed to [holder], which is what
* every OwU that has moved looks like: a genesis to oneself, then a
* transfer.
*/
private fun handedOver(debtor: Signer, holder: String, metadata: Metadata, at: Long): Iou =
Ledger.transfer(Ledger.issue(debtor, metadata, timestamp = at), debtor, holder, at + 60)
}
@@ -0,0 +1,25 @@
package net.helcel.owu.helper
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
fun Context.toast(text: String) = Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
fun Context.copyToClipboard(label: String, text: String) {
getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText(label, text))
}
fun Context.clipboardText(): String? =
getSystemService(ClipboardManager::class.java)?.primaryClip?.takeIf { it.itemCount > 0 }
?.getItemAt(0)?.coerceToText(this)?.toString()
private val timeFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
fun formatTime(epochSeconds: Long): String =
timeFormat.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()))
@@ -0,0 +1,20 @@
package net.helcel.owu.helper
import android.content.Context
import net.helcel.owu.ledger.Iou
/** What an OwU's own conditions say about here and now. */
object Gates {
/**
* True when the OwU names a place and we are elsewhere. Never a bar: it
* only paints the line red, since only the one who wrote it can say
* whether it counts. Without permission or a fix we cannot say we are
* away, so we do not say it.
*/
suspend fun outOfPlace(context: Context, iou: Iou): Boolean {
val geo = iou.metadata.geoloc ?: return false
if (!Locator.hasPermission(context)) return false
val loc = Locator.current(context) ?: return false
return Locator.distanceTo(loc, geo) > geo.radiusM
}
}
@@ -0,0 +1,25 @@
package net.helcel.owu.helper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.helcel.owu.crypto.Keys
import net.helcel.owu.ledger.IouJson
/** What goes in the identity QR code: who you are and how to address you. */
@Serializable
data class IdentityCard(
@SerialName("v") val version: Int = 1,
val name: String,
val key: String,
) {
fun encode(): String = IouJson.json.encodeToString(serializer(), this)
companion object {
/** Null unless [text] is a card carrying a usable key. */
fun decode(text: String): IdentityCard? = try {
IouJson.json.decodeFromString(serializer(), text).takeIf { Keys.decode(it.key) != null }
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,79 @@
package net.helcel.owu.helper
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import net.helcel.owu.ledger.GeoLoc
import kotlin.coroutines.resume
/**
* One fix, straight from the platform's LocationManager. No fused provider:
* that is Play Services.
*/
object Locator {
const val PERMISSION = Manifest.permission.ACCESS_FINE_LOCATION
fun hasPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, PERMISSION) == PackageManager.PERMISSION_GRANTED
/** Metres from [location] to the centre of [geoloc]. */
fun distanceTo(location: Location, geoloc: GeoLoc): Float {
val out = FloatArray(1)
Location.distanceBetween(location.latitude, location.longitude, geoloc.latitude, geoloc.longitude, out)
return out[0]
}
/**
* The current position, waiting up to [timeoutMs] for a fresh fix and
* falling back to the last known one. Null if the device has neither.
*/
// Lint cannot see the permission check through the suspend boundary; it is the first line.
@SuppressLint("MissingPermission")
suspend fun current(context: Context, timeoutMs: Long = 10_000): Location? {
if (!hasPermission(context)) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val provider = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.firstOrNull { runCatching { manager.isProviderEnabled(it) }.getOrDefault(false) }
?: return null
val fresh = withTimeoutOrNull(timeoutMs) { request(context, manager, provider) }
return fresh ?: runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
}
@SuppressLint("MissingPermission") // only reached through [current], which checks
private suspend fun request(context: Context, manager: LocationManager, provider: String): Location? =
suspendCancellableCoroutine { cont ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(provider, signal, context.mainExecutor) { if (cont.isActive) cont.resume(it) }
} else {
// All four methods spelled out: before API 30 none of them
// has a default, and a lambda would miss the other three.
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (cont.isActive) cont.resume(location)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {
if (cont.isActive) cont.resume(null)
}
}
@Suppress("DEPRECATION")
manager.requestSingleUpdate(provider, listener, Looper.getMainLooper())
cont.invokeOnCancellation { manager.removeUpdates(listener) }
}
}
}
@@ -0,0 +1,12 @@
package net.helcel.owu.helper
import android.content.Context
import android.content.SharedPreferences
/** The preference file opened directly, under the name and mode androidx's
* PreferenceManager uses, so the preference UI framework is not pulled in. */
fun defaultPreferences(ctx: Context): SharedPreferences =
ctx.getSharedPreferences(ctx.packageName + "_preferences", Context.MODE_PRIVATE)
@@ -0,0 +1,22 @@
package net.helcel.owu.ledger
import kotlinx.serialization.json.Json
/**
* The transport and storage encoding. Only [net.helcel.owu.crypto.Canonical]
* bytes are ever signed, so this can grow fields freely.
*/
object IouJson {
val json = Json {
classDiscriminator = "action"
encodeDefaults = true
explicitNulls = false
ignoreUnknownKeys = true
}
fun encode(iou: Iou): String = json.encodeToString(Iou.serializer(), iou)
fun decode(text: String): Iou = json.decodeFromString(Iou.serializer(), text)
fun encode(agreement: ExchangeAgreement): String = json.encodeToString(ExchangeAgreement.serializer(), agreement)
fun decodeAgreement(text: String): ExchangeAgreement = json.decodeFromString(ExchangeAgreement.serializer(), text)
}
@@ -0,0 +1,160 @@
package net.helcel.owu.ledger
import net.helcel.owu.crypto.Signer
import java.util.UUID
class LedgerException(message: String) : Exception(message)
/**
* The only way blocks get made. Each operation checks what it needs from the
* current state, signs, appends, and then has [Verifier] accept the result
* before returning it, so a caller can never be handed a chain that a peer
* would reject.
*/
object Ledger {
fun now(): Long = System.currentTimeMillis() / 1000
/**
* Writes a promise. It starts held by the person who made it - an OwU
* towards yourself - and becomes someone else's when it is traded away.
* [creditor] names another holder only where a chain has to be built
* whole, as in tests and fixtures.
*/
fun issue(
signer: Signer,
metadata: Metadata,
creditor: String = signer.publicKey,
id: String = UUID.randomUUID().toString(),
timestamp: Long = now(),
): Iou {
val nb = metadata.notBefore
val na = metadata.notAfter
if (nb != null && na != null && nb > na) throw LedgerException("window closes before it opens")
val unsigned = Block.Issue(
timestamp = timestamp,
debtor = signer.publicKey,
creditor = creditor,
metadataHash = metadata.hash(),
)
val genesis = unsigned.copy(signature = signer.sign(unsigned.signedBytes(id)))
return accept(Iou(id, metadata, listOf(genesis)))
}
fun transfer(iou: Iou, signer: Signer, transferee: String, timestamp: Long = now()): Iou {
val state = active(iou)
if (state.holder != signer.publicKey) throw LedgerException("only the holder can transfer")
if (transferee == signer.publicKey) throw LedgerException("cannot transfer to yourself")
val unsigned = Block.Transfer(
sequence = state.length,
timestamp = timestamp,
parentHash = state.headHash,
transferor = signer.publicKey,
transferee = transferee,
)
return append(iou, unsigned.copy(signature = signer.sign(unsigned.signedBytes(iou.id))))
}
/**
* The debtor closes their own promise, which is what becomes of an OwU
* handed back to the person who made it. Nothing can follow.
*/
fun redeem(iou: Iou, signer: Signer, timestamp: Long = now()): Iou {
val state = active(iou)
if (state.debtor != signer.publicKey) throw LedgerException("only the debtor can close a promise")
if (state.holder != signer.publicKey) throw LedgerException("the OwU has to be back with you first")
val unsigned = Block.Redeemed(
sequence = state.length,
timestamp = timestamp,
parentHash = state.headHash,
debtor = signer.publicKey,
)
return append(iou, unsigned.copy(signature = signer.sign(unsigned.signedBytes(iou.id))))
}
/**
* Step one of a swap: the holder of [mine] offers all of it for all of
* [theirs], signing the left side. Either bundle may hold several OwUs
* and they stand or fall together. [theirs] is the counterparty's side as
* we last saw it.
*/
fun proposeExchange(
mine: List<Iou>,
theirs: List<Iou>,
signer: Signer,
id: String = UUID.randomUUID().toString(),
timestamp: Long = now(),
): ExchangeAgreement {
if (mine.isEmpty() || theirs.isEmpty()) throw LedgerException("a swap needs something on both sides")
val mineStates = mine.map { it to active(it) }
val theirsStates = theirs.map { it to active(it) }
val theirIds = theirs.map { it.id }.toSet()
if (mine.any { it.id in theirIds }) throw LedgerException("cannot swap an OwU with itself")
if (mineStates.any { it.second.holder != signer.publicKey }) throw LedgerException("only the holder can offer an OwU")
if (theirsStates.any { it.second.holder == signer.publicKey }) throw LedgerException("you already hold the other OwU")
val holder = signer.publicKey
val theirHolder = theirsStates.first().second.holder
if (theirsStates.any { it.second.holder != theirHolder }) throw LedgerException("their side is held by more than one person")
val draft = ExchangeAgreement(
id = id,
timestamp = timestamp,
left = ExchangeSide(holder, mineStates.map { ExchangeRef(it.first.id, it.second.headHash) }),
right = ExchangeSide(theirHolder, theirsStates.map { ExchangeRef(it.first.id, it.second.headHash) }),
)
return draft.copy(left = draft.left.copy(signature = signer.sign(draft.signingBytes())))
}
/**
* Step two: the counterparty, holding the right-hand side, countersigns.
* [mine] is their copy of every OwU on it, each of which must still be at
* the head the proposal was made against.
*/
fun acceptExchange(proposal: ExchangeAgreement, mine: List<Iou>, signer: Signer): ExchangeAgreement {
if (proposal.left.signature == null) throw LedgerException("proposal is not signed by the proposer")
if (proposal.right.signature != null) throw LedgerException("proposal is already accepted")
if (proposal.right.holder != signer.publicKey) throw LedgerException("proposal names a different holder")
val byId = mine.associateBy { it.id }
if (byId.size != proposal.right.ious.size || proposal.right.ious.any { it.iouId !in byId })
throw LedgerException("proposal is not for these OwUs")
proposal.right.ious.forEach { ref ->
val state = active(byId.getValue(ref.iouId))
if (state.holder != signer.publicKey) throw LedgerException("only the holder can accept")
if (ref.headHash != state.headHash) throw LedgerException("OwU has changed since the proposal")
}
return proposal.copy(right = proposal.right.copy(signature = signer.sign(proposal.signingBytes())))
}
/** Step three, run once per OwU by whoever has the completed agreement. */
fun applyExchange(iou: Iou, agreement: ExchangeAgreement): Iou {
if (!agreement.complete) throw LedgerException("agreement is not signed by both sides")
val side = agreement.side(iou.id) ?: throw LedgerException("agreement does not name this OwU")
val state = active(iou)
if (side.ref(iou.id)!!.headHash != state.headHash) throw LedgerException("OwU has changed since the agreement")
val block = Block.Transfer(
sequence = state.length,
timestamp = agreement.timestamp,
parentHash = state.headHash,
transferor = side.holder,
transferee = agreement.other(iou.id)!!.holder,
agreement = agreement,
signature = side.signature!!,
)
return append(iou, block)
}
private fun valid(iou: Iou): IouState = when (val v = Verifier.verify(iou)) {
is Verdict.Valid -> v.state
is Verdict.Invalid -> throw LedgerException("invalid chain at block ${v.sequence}: ${v.reason}")
}
private fun active(iou: Iou): IouState = valid(iou).also {
if (it.status != Status.ACTIVE) throw LedgerException("OwU is ${it.status}")
}
private fun append(iou: Iou, block: Block): Iou = accept(iou.copy(chain = iou.chain + block))
private fun accept(iou: Iou): Iou {
valid(iou)
return iou
}
}
@@ -0,0 +1,290 @@
package net.helcel.owu.ledger
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.helcel.owu.crypto.Canonical
import net.helcel.owu.crypto.Hash
/**
* One IOU: a signed chain of custody. The chain is only meaningful once
* [Verifier.verify] has accepted it; nothing here checks anything.
*/
@Serializable
data class Iou(
@SerialName("iou_id") val id: String,
val metadata: Metadata,
@SerialName("ledger_chain") val chain: List<Block>,
) {
val head: Block get() = chain.last()
fun headHash(): String = head.hash(id)
}
/**
* What the IOU is. Covered by the genesis signature through [hash], so a
* "1 Heavy Hug" cannot become "1 Car" after issue.
*
* [notBefore]/[notAfter] (epoch seconds) bound redemption. Like [geoloc] they
* are shown, not enforced: clocks are self-reported, and the debtor decides.
*/
@Serializable
data class Metadata(
@SerialName("display_title") val title: String,
/** Whatever else the promise needs said: terms, an occasion, a joke. */
@SerialName("description") val description: String? = null,
@SerialName("template_id") val templateId: String? = null,
val geoloc: GeoLoc? = null,
@SerialName("not_before") val notBefore: Long? = null,
@SerialName("not_after") val notAfter: Long? = null,
) {
// All of it signed with the genesis block, description included: neither
// side can edit what was promised.
fun canonical(): Map<String, Any?> = mapOf(
"display_title" to title,
"description" to description,
"template_id" to templateId,
"geoloc" to geoloc?.canonical(),
"not_before" to notBefore,
"not_after" to notAfter,
)
fun hash(): String = Hash.sha256Hex(Canonical.bytes(canonical()))
val hasWindow: Boolean get() = notBefore != null || notAfter != null
/** Where [now] falls relative to the redemption window. */
fun timeGate(now: Long = Ledger.now()): TimeGate = when {
notBefore != null && now < notBefore -> TimeGate.NotYet(notBefore)
notAfter != null && now > notAfter -> TimeGate.Expired(notAfter)
else -> TimeGate.Open
}
}
sealed class TimeGate {
object Open : TimeGate()
data class NotYet(val opensAt: Long) : TimeGate()
data class Expired(val closedAt: Long) : TimeGate()
}
/** Where a promise is to be kept. Micro-degrees, so the signed form is
* integer-only. Never verified: a hint the app shows, not a rule. */
@Serializable
data class GeoLoc(
@SerialName("lat_e6") val latE6: Int,
@SerialName("lon_e6") val lonE6: Int,
@SerialName("radius_m") val radiusM: Int,
val label: String? = null,
) {
val latitude: Double get() = latE6 / 1e6
val longitude: Double get() = lonE6 / 1e6
fun canonical(): Map<String, Any?> = mapOf(
"lat_e6" to latE6,
"lon_e6" to lonE6,
"radius_m" to radiusM,
"label" to label,
)
companion object {
fun of(latitude: Double, longitude: Double, radiusM: Int, label: String? = null) = GeoLoc(
latE6 = Math.round(latitude * 1e6).toInt(),
lonE6 = Math.round(longitude * 1e6).toInt(),
radiusM = radiusM,
label = label,
)
}
}
enum class Action { ISSUE, TRANSFER, REDEEMED }
/**
* One state change; the wire `action` is the subclass's serial name. Bound to
* its IOU (the id is signed) and to its predecessor via [parentHash], the
* SHA-256 of the previous block's canonical form, signature included. By hash
* and not by the raw signature, so ECDSA malleability cannot make two
* byte-different chains of one history.
*/
@Serializable
sealed class Block {
abstract val sequence: Int
abstract val timestamp: Long
abstract val parentHash: String
abstract val signature: String
abstract val action: Action
/** Fields specific to this action, without the common ones or the signature. */
protected abstract fun fields(): Map<String, Any?>
/** The identity whose key must have produced [signature], or null if the block cannot say. */
abstract fun signer(iouId: String): String?
/** Everything the signature covers, as a canonical map. */
fun payload(iouId: String): Map<String, Any?> = fields() + mapOf(
"iou_id" to iouId,
"sequence" to sequence,
"action" to action.name,
"timestamp" to timestamp,
"parent_hash" to parentHash,
)
/** The bytes [signature] is over. The payload, unless a swap signs its agreement instead. */
open fun signedBytes(iouId: String): ByteArray = Canonical.bytes(payload(iouId))
/** The value the next block's [parentHash] must carry. */
fun hash(iouId: String): String =
Hash.sha256Hex(Canonical.bytes(payload(iouId) + ("signature" to signature)))
/** Block 0: the debtor writes the OwU and hands it to the creditor. */
@Serializable
@SerialName("ISSUE")
data class Issue(
override val sequence: Int = 0,
override val timestamp: Long,
@SerialName("parent_hash") override val parentHash: String = Hash.ZERO,
@SerialName("debtor_pub_key") val debtor: String,
@SerialName("creditor_pub_key") val creditor: String,
@SerialName("metadata_hash") val metadataHash: String,
override val signature: String = "",
) : Block() {
override val action get() = Action.ISSUE
override fun fields() = mapOf(
"debtor_pub_key" to debtor,
"creditor_pub_key" to creditor,
"metadata_hash" to metadataHash,
)
override fun signer(iouId: String) = debtor
}
/**
* The holder passes the OwU on: plain, a gift or a hand-back.
*
* With an [agreement], one half of a swap. The same agreement lands on
* both chains, each block signed by that chain's holder with their
* signature from it, so once both have signed either party can append
* both, and neither half is valid alone. That is why such a block signs
* the agreement and not its own payload: one signature has to commit to
* both sides.
*/
@Serializable
@SerialName("TRANSFER")
data class Transfer(
override val sequence: Int,
override val timestamp: Long,
@SerialName("parent_hash") override val parentHash: String,
@SerialName("transferor_pub_key") val transferor: String,
@SerialName("transferee_pub_key") val transferee: String,
val agreement: ExchangeAgreement? = null,
override val signature: String = "",
) : Block() {
override val action get() = Action.TRANSFER
// A null agreement is dropped by Canonical, so a plain hand-over signs
// exactly the two keys and the common fields.
override fun fields() = mapOf(
"transferor_pub_key" to transferor,
"transferee_pub_key" to transferee,
"agreement" to agreement?.canonical(),
)
override fun signer(iouId: String) = transferor
override fun signedBytes(iouId: String) =
agreement?.signingBytes() ?: super.signedBytes(iouId)
}
/** The debtor honours the promise: the OwU is redeemed. Terminal. */
@Serializable
@SerialName("REDEEMED")
data class Redeemed(
override val sequence: Int,
override val timestamp: Long,
@SerialName("parent_hash") override val parentHash: String,
@SerialName("debtor_pub_key") val debtor: String,
override val signature: String = "",
) : Block() {
override val action get() = Action.REDEEMED
override fun fields() = mapOf("debtor_pub_key" to debtor)
override fun signer(iouId: String) = debtor
}
}
/** One OwU in an exchange, pinned to the head it was agreed against. */
@Serializable
data class ExchangeRef(
@SerialName("iou_id") val iouId: String,
@SerialName("head_hash") val headHash: String,
) {
fun canonical(): Map<String, Any?> = mapOf("iou_id" to iouId, "head_hash" to headHash)
}
/** One party to an exchange: all they put in, and their consent to the whole.
* Five beers and a hug are one deal, signed once, all or nothing. */
@Serializable
data class ExchangeSide(
@SerialName("holder_pub_key") val holder: String,
val ious: List<ExchangeRef> = emptyList(),
/** The holder's signature over [ExchangeAgreement.signingBytes]; null until they have signed. */
val signature: String? = null,
) {
/** Ordered, so both sides sign the same bytes whatever order they built it in. */
fun canonical(): Map<String, Any?> = mapOf(
"holder_pub_key" to holder,
"ious" to ious.sortedBy { it.iouId }.map { it.canonical() },
)
fun ref(iouId: String): ExchangeRef? = ious.firstOrNull { it.iouId == iouId }
fun holds(iouId: String): Boolean = ref(iouId) != null
}
/** [left] for [right], both holders signing the same bytes: the core without
* either signature. Every OwU is pinned to a head hash, so one of them
* moving before the swap lands voids the whole bundle. */
@Serializable
data class ExchangeAgreement(
@SerialName("exchange_id") val id: String,
val timestamp: Long,
val left: ExchangeSide,
val right: ExchangeSide,
) {
/** The core with both signatures, so a block hash covers the consent that made it valid. */
fun canonical(): Map<String, Any?> = mapOf(
"exchange_id" to id,
"timestamp" to timestamp,
"left" to left.canonical() + ("signature" to left.signature),
"right" to right.canonical() + ("signature" to right.signature),
)
fun signingBytes(): ByteArray = Canonical.bytes(
mapOf(
"exchange_id" to id,
"timestamp" to timestamp,
"left" to left.canonical(),
"right" to right.canonical(),
)
)
val complete: Boolean get() = left.signature != null && right.signature != null
fun side(iouId: String): ExchangeSide? = when {
left.holds(iouId) -> left
right.holds(iouId) -> right
else -> null
}
fun other(iouId: String): ExchangeSide? = when {
left.holds(iouId) -> right
right.holds(iouId) -> left
else -> null
}
}
enum class Status { ACTIVE, REDEEMED }
/** What a valid chain says right now. */
data class IouState(
val debtor: String,
val holder: String,
val status: Status,
val length: Int,
val headHash: String,
)
@@ -0,0 +1,113 @@
package net.helcel.owu.ledger
import net.helcel.owu.crypto.Hash
import net.helcel.owu.crypto.Keys
sealed class Verdict {
data class Valid(val state: IouState) : Verdict()
data class Invalid(val sequence: Int, val reason: String) : Verdict()
val stateOrNull: IouState? get() = (this as? Valid)?.state
}
/**
* Decides whether a received chain is what it claims to be. Every rule that
* makes a chain acceptable lives here and nowhere else; the builders in
* [Ledger] run their output through it rather than trusting themselves.
*/
object Verifier {
private class Rejected(val sequence: Int, val reason: String) : Exception(reason)
fun verify(iou: Iou): Verdict = try {
Verdict.Valid(walk(iou))
} catch (e: Rejected) {
Verdict.Invalid(e.sequence, e.reason)
}
private fun walk(iou: Iou): IouState {
val chain = iou.chain
if (chain.isEmpty()) throw Rejected(0, "empty chain")
val genesis = chain[0] as? Block.Issue ?: throw Rejected(0, "first block is not an ISSUE")
if (genesis.sequence != 0) throw Rejected(0, "genesis sequence is not 0")
if (genesis.parentHash != Hash.ZERO) throw Rejected(0, "genesis has a parent")
if (genesis.metadataHash != iou.metadata.hash()) throw Rejected(0, "metadata does not match its hash")
checkSignature(iou.id, genesis)
var state = IouState(
debtor = genesis.debtor,
holder = genesis.creditor,
status = Status.ACTIVE,
length = 1,
headHash = genesis.hash(iou.id),
)
for (n in 1 until chain.size) {
val block = chain[n]
if (block.sequence != n) throw Rejected(n, "sequence is ${block.sequence}, expected $n")
if (block.parentHash != state.headHash) throw Rejected(n, "parent hash does not match previous block")
if (state.status == Status.REDEEMED) throw Rejected(n, "block after redemption")
checkSignature(iou.id, block)
state = when (block) {
is Block.Issue -> throw Rejected(n, "a second ISSUE")
is Block.Transfer -> transfer(n, iou.id, block, state)
is Block.Redeemed -> redeemed(n, block, state)
}.copy(length = n + 1, headHash = block.hash(iou.id))
}
return state
}
private fun checkSignature(iouId: String, block: Block) {
val signer = block.signer(iouId) ?: throw Rejected(block.sequence, "block names no signer for this OwU")
if (!Keys.verify(signer, block.signedBytes(iouId), block.signature))
throw Rejected(block.sequence, "signature does not verify")
}
/**
* A hand-over. The three rules above the agreement hold whether it is a
* gift or half a swap; the rest bind this block to the other chain's.
*/
private fun transfer(n: Int, iouId: String, b: Block.Transfer, s: IouState): IouState {
if (s.status != Status.ACTIVE) throw Rejected(n, "transfer of an OwU that is ${s.status}")
if (b.transferor != s.holder) throw Rejected(n, "transferor is not the holder")
if (b.transferee == b.transferor) throw Rejected(n, "transfer to self")
val a = b.agreement ?: return s.copy(holder = b.transferee)
val mine = a.side(iouId) ?: throw Rejected(n, "agreement does not name this OwU")
val theirs = a.other(iouId)!!
if (theirs.holds(iouId)) throw Rejected(n, "agreement swaps an OwU with itself")
// The block says who it moves between and the agreement says the same,
// or one of the two is lying about the deal this signature covers.
if (mine.holder != b.transferor) throw Rejected(n, "agreement is not the transferor's side")
if (theirs.holder != b.transferee) throw Rejected(n, "agreement sends it to somebody else")
if (theirs.ious.isEmpty()) throw Rejected(n, "exchange for nothing")
// Every OwU in the bundle is pinned; this one has to be at the head it
// was signed against, or the deal both sides agreed to has changed.
if (mine.ref(iouId)!!.headHash != b.parentHash) throw Rejected(
n,
"agreement was made against a different head"
)
if (b.timestamp != a.timestamp) throw Rejected(n, "block timestamp differs from agreement")
if (!a.complete) throw Rejected(n, "agreement is not signed by both sides")
if (b.signature != mine.signature) throw Rejected(n, "block signature is not this side's agreement signature")
// Ours was checked in checkSignature; the counterparty's consent is
// what makes the swap binding, so it is checked here.
if (!Keys.verify(theirs.holder, a.signingBytes(), theirs.signature!!))
throw Rejected(n, "counterparty signature does not verify")
return s.copy(holder = b.transferee)
}
/**
* A promise is closed by the one who made it, and only once it has come
* back to them - which is what redeeming is: the OwU is handed home and
* its maker voids it. There is no state in between.
*/
private fun redeemed(n: Int, b: Block.Redeemed, s: IouState): IouState {
if (s.status != Status.ACTIVE) throw Rejected(n, "redeeming an OwU that is ${s.status}")
if (b.debtor != s.debtor) throw Rejected(n, "redeemed by someone who is not the debtor")
if (s.holder != s.debtor) throw Rejected(n, "redeemed while somebody else held it")
return s.copy(status = Status.REDEEMED)
}
}
@@ -0,0 +1,21 @@
package net.helcel.owu.peer
import net.helcel.owu.ledger.Iou
/**
* What a finished table amounted to, for whatever says so on screen. One per
* protocol step, not per event: a redemption reaches the debtor as a gift and
* a receipt together, and that is one thing that happened.
*/
data class Outcome(
/** Their public key, when the handshake got that far. */
val peer: String?,
val gave: List<Iou> = emptyList(),
val got: List<Iou> = emptyList(),
val redeemed: List<Iou> = emptyList(),
/** Or their "no", to being asked to the table or to what was on it. */
val declined: PeerMessage.Asked? = null,
) {
val isEmpty: Boolean
get() = gave.isEmpty() && got.isEmpty() && redeemed.isEmpty() && declined == null
}
@@ -0,0 +1,565 @@
package net.helcel.owu.peer
import net.helcel.owu.crypto.Canonical
import net.helcel.owu.crypto.Hash
import net.helcel.owu.crypto.Keys
import net.helcel.owu.crypto.Signer
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.Block
import net.helcel.owu.ledger.ExchangeAgreement
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouState
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.LedgerException
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.IouStore
import net.helcel.owu.store.Merge
import java.security.SecureRandom
/**
* One side of a session, knowing nothing of Bluetooth. Feed it messages and
* user actions; it returns what to send and what happened. Run one thread at
* a time by the transport.
*
* After HELLO/AUTH the peer is proven. Then: a **table**, each side putting
* down an OwU or nothing and both accepting, or a **redemption**, a holder
* asking and the debtor honouring. Giving, issuing and swapping are the table.
*/
class PeerEngine(
private val store: IouStore,
private val signer: Signer,
private val myName: String,
) {
sealed class Event {
data class PeerIdentified(val card: IdentityCard) : Event()
/** The peer started over (new HELLO); anything pending with them is void. */
object Restarted : Event()
/** The table changed: what is on it, and who has accepted it as it stands. */
data class Tabled(val table: Table) : Event()
/** Both accepted and it happened: what left, what arrived, either possibly nothing. */
data class Done(val gave: List<Iou> = emptyList(), val got: List<Iou> = emptyList()) : Event()
/** A promise came home and was closed: mine to file, or theirs, now spent. */
data class Redeemed(val iou: Iou) : Event()
/** They have opened a table with us and would like us at it. */
data class Invited(val card: IdentityCard) : Event()
/** They said no, to being asked to the table or to what was on it. */
data class Declined(val card: IdentityCard, val to: PeerMessage.Asked) : Event()
data class Failed(val reason: String) : Event()
}
data class Step(val send: List<PeerMessage> = emptyList(), val events: List<Event> = emptyList())
/** The table as this side sees it. Each side is a bundle: five beers and
* two hugs are one deal, accepted once, moving together or not at all. */
data class Table(
val mine: List<Iou> = emptyList(),
val theirs: List<Iou> = emptyList(),
/** I have said yes to the table as it stands. */
val accepted: Boolean = false,
/** They have said yes to the table as it stands. */
val theyAccepted: Boolean = false,
/** Their OwU contradicts the copy I hold: signed over twice. Nothing
* may be accepted against it. */
val conflict: Boolean = false,
) {
val empty: Boolean get() = mine.isEmpty() && theirs.isEmpty()
}
val me: String get() = signer.publicKey
private var myNonce: String = freshNonce()
private fun freshNonce() = ByteArray(16).also { SecureRandom().nextBytes(it) }.let { Hash.hex(it) }
/** The peer, once they have proven they hold their key. */
var peer: IdentityCard? = null
private set
private var peerHello: PeerMessage.Hello? = null
private var earlyAuth: PeerMessage.Auth? = null
private var myOffer: List<Iou> = emptyList()
private var theirOffer: List<Iou> = emptyList()
/** The deal each side has accepted, if any; stale as soon as the table changes. */
private var myAccepted: String? = null
private var theirAccepted: String? = null
/** OwUs minted from templates for this table, until they leave or are dropped. */
private val minted = mutableSetOf<String>()
/** Their OwU contradicts a copy we hold; set when it lands on the table. */
private var theirConflict = false
/** Their half-signed swap, held until I accept too. */
private var theirProposal: ExchangeAgreement? = null
/** My own half-signed swap for this table, so it is signed only once. */
private var myProposal: ExchangeAgreement? = null
fun start(): Step = Step(send = listOf(myHello()))
fun onMessage(m: PeerMessage): Step = try {
when (m) {
is PeerMessage.Hello -> hello(m)
is PeerMessage.Auth -> auth(m)
else -> {
if (peer == null) unknown()
else when (m) {
is PeerMessage.Invite -> invited(m)
is PeerMessage.Decline -> declined(m)
is PeerMessage.Table -> tabled(m)
is PeerMessage.Accept -> accepted(m)
is PeerMessage.Give -> given(m)
is PeerMessage.Redeemed -> redeemed(m)
is PeerMessage.Hello, is PeerMessage.Auth -> throw IllegalStateException("unreachable")
}
}
}
} catch (e: Exception) {
Step(events = listOf(Event.Failed(e.message ?: e.toString())))
}
// --- handshake ---------------------------------------------------------
/** Every HELLO is answered with AUTH, so a peer that missed our answer can
* ask again. Only a *new* nonce from a verified peer means they restarted. */
private fun hello(m: PeerMessage.Hello): Step {
if (Keys.decode(m.identity.key) == null) throw IllegalStateException("peer key unreadable")
if (m.identity.key == me) throw IllegalStateException("peer is this device")
val previous = peerHello
if (previous != null && previous.identity.key != m.identity.key) throw IllegalStateException("HELLO from a different key")
val auth = PeerMessage.Auth(signer.sign(authBytes(m.nonce, me)))
if (peer != null && previous != null && previous.nonce != m.nonce) {
reset()
peerHello = m
return Step(send = listOf(myHello(), auth), events = listOf(Event.Restarted))
}
peerHello = m
// Their AUTH may have overtaken their HELLO; now it can be checked.
val early = earlyAuth?.also { earlyAuth = null }
if (early != null && peer == null && verifies(m, early)) {
peer = m.identity
store.putMet(m.identity.key, m.identity.name)
return Step(send = listOf(auth), events = listOf(Event.PeerIdentified(m.identity)))
}
return Step(send = listOf(auth))
}
private fun verifies(h: PeerMessage.Hello, a: PeerMessage.Auth): Boolean =
Keys.verify(h.identity.key, authBytes(myNonce, h.identity.key), a.signature)
/** Our HELLO again, for a peer that has not answered; same nonce, so nothing restarts. */
fun reintroduce(): Step = Step(send = listOf(myHello()))
/**
* They talk as though we knew them and we do not: their session outlived
* ours, or our radio restarted under them. Dropping it leaves them waiting
* for ever - the one deadlock this protocol had. So say who we are: an
* unseen nonce restarts them, and they can try again.
*/
private fun unknown(): Step = Step(send = listOf(myHello()))
private fun myHello() = PeerMessage.Hello(IdentityCard(name = myName, key = me), myNonce)
private fun reset() {
abandon()
peer = null
peerHello = null
earlyAuth = null
myNonce = freshNonce()
clearTable()
}
private fun auth(m: PeerMessage.Auth): Step {
val h = peerHello
if (h == null) {
// Nothing to check it against yet; keep it for when their HELLO arrives.
earlyAuth = m
return Step()
}
if (!verifies(h, m)) throw IllegalStateException("peer failed to prove its key")
// A repeat of a good AUTH (they answered our HELLO twice) changes nothing.
if (peer != null) return Step()
peer = h.identity
// The key is proven; remember the name they claim so screens read as
// people rather than hashes.
store.putMet(h.identity.key, h.identity.name)
return Step(events = listOf(Event.PeerIdentified(h.identity)))
}
private val peerKey: String get() = peer?.key ?: throw IllegalStateException("no peer")
// --- the table ---------------------------------------------------------
fun table(): Table = Table(
mine = myOffer,
theirs = theirOffer,
accepted = myAccepted != null && myAccepted == deal(),
theyAccepted = theirAccepted != null && theirAccepted == deal(),
conflict = theirConflict,
)
/**
* My side of the table: [held] as they are, plus one fresh OwU per [mint]
* entry - five beers is the beer template five times. An empty call takes
* my side off. Changing the table withdraws both yeses, since nobody is
* held to a yes given to something else.
*/
fun put(held: List<Iou> = emptyList(), mint: List<Metadata> = emptyList()): Step {
held.forEach {
if (active(it).holder != me) throw LedgerException("you do not hold that OwU")
}
// Minted here, not by the caller, so a bundle that never leaves takes
// its fresh ious with it.
val fresh = mint.map { Ledger.issue(signer, it).also { iou -> store.put(iou) } }
val offer = held + fresh
discardMinted(keep = offer.map { it.id }.toSet())
minted += fresh.map { it.id }
myOffer = offer
forget()
return Step(send = listOf(PeerMessage.Table(me, offer)), events = listOf(Event.Tabled(table())))
}
/** Mints one promise from a template onto the table alone: writing one mid-trade. */
fun putNew(metadata: Metadata): Step = put(mint = listOf(metadata))
/** Nothing came of the table: forget anything minted for it. */
fun abandon() {
discardMinted(keep = emptySet())
}
/** Drops ious minted here and not in [keep], but only while still a bare
* genesis in my hands. Once one has moved it is somebody's promise. */
private fun discardMinted(keep: Set<String>) {
val going = minted - keep
minted.retainAll(keep)
going.forEach { id ->
val iou = store.ious.value[id] ?: return@forEach
val state = Verifier.verify(iou).stateOrNull ?: return@forEach
if (iou.chain.size == 1 && state.holder == me && state.debtor == me) store.remove(id)
}
}
/**
* Their side of the table, from a TABLE or riding on their acceptance.
*
* Checked against what we hold *before* anyone agrees: the one moment a
* double-spend is caught without the debtor in the room. Contradicting
* histories are one such; so is an OwU offered at a head we know has moved
* on. One bad OwU taints the bundle, since it moves as one.
*/
private fun adoptTheirs(ious: List<Iou>) {
ious.forEach {
if (active(it).holder != peerKey) throw IllegalStateException("they offer an OwU they do not hold")
}
if (ious.map { it.id }.toSet().size != ious.size) throw IllegalStateException("the same OwU twice")
theirOffer = ious
theirConflict = ious.any { iou ->
when (store.compare(iou)) {
is Merge.Fork -> true
Merge.Unchanged -> iou.chain.size < (store.ious.value[iou.id]?.chain?.size ?: 0)
else -> false
}
}
}
/** Sent on opening a table. Carries and changes nothing; being asked is all of it. */
fun invite(): Step = Step(send = listOf(PeerMessage.Invite(me)))
private fun invited(m: PeerMessage.Invite): Step {
val card = peer ?: throw IllegalStateException("no peer")
if (m.key != card.key) throw IllegalStateException("that invitation is signed by somebody else")
return Step(events = listOf(Event.Invited(card)))
}
/** Turn them down. Refusing the table clears it both sides, and a minted
* OwU that never left goes with it. */
fun decline(to: PeerMessage.Asked): Step {
val send = listOf(PeerMessage.Decline(me, to))
if (to != PeerMessage.Asked.TABLE) return Step(send = send)
abandon()
clearTable()
return Step(send = send, events = listOf(Event.Tabled(table())))
}
private fun declined(m: PeerMessage.Decline): Step {
val card = peer ?: throw IllegalStateException("no peer")
if (m.key != card.key) throw IllegalStateException("that refusal is signed by somebody else")
val said = Event.Declined(card, m.to)
if (m.to != PeerMessage.Asked.TABLE) return Step(events = listOf(said))
abandon()
clearTable()
return Step(events = listOf(said, Event.Tabled(table())))
}
private fun tabled(m: PeerMessage.Table): Step {
adoptTheirs(m.ious)
forget()
return Step(events = listOf(Event.Tabled(table())))
}
/** Yes to the table as it stands. Two yeses to the same table and it
* happens: a swap if both brought something, a transfer if one did. */
fun accept(): Step {
val deal = deal() ?: throw LedgerException("there is nothing on the table")
if (theirConflict) throw LedgerException("that OwU contradicts the copy you hold; it has been signed over twice")
myAccepted = deal
val step = advance()
if (step.send.isNotEmpty()) return step
// My side rides along with my yes, so losing the TABLE cannot strand it.
return Step(
send = listOf(PeerMessage.Accept(me, deal, myOffer)),
events = listOf(Event.Tabled(table())),
)
}
private fun accepted(m: PeerMessage.Accept): Step {
// An acceptance carries its own side, so it stands alone. The TABLE
// before it may never have arrived - a big fragmented message where
// this one is small - which used to leave the table empty and the
// acceptance refused as "they accepted with nothing on the table".
if (m.ious.isNotEmpty() && m.ious.map { it.id }.toSet() != theirOffer.map { it.id }.toSet()) {
adoptTheirs(m.ious)
}
val deal = deal() ?: throw IllegalStateException("they accepted with nothing on the table")
if (m.deal != deal) throw IllegalStateException("the table changed while they were accepting")
m.agreement?.let { a ->
if (a.complete) return complete(a, m.ious)
checkProposal(a)
theirProposal = a
}
theirAccepted = deal
val step = advance()
if (step.send.isNotEmpty() || step.events.isNotEmpty()) return step
return Step(events = listOf(Event.Tabled(table())))
}
/**
* What my yes now allows. For a swap it carries a signature: the side
* holding the lowest OwU id half-signs over both bundles, the other
* countersigns, and countersigning is what makes it happen.
*/
private fun advance(): Step {
val deal = deal() ?: return Step()
if (myAccepted != deal) return Step()
val mine = myOffer
val theirs = theirOffer
return when {
mine.isNotEmpty() && theirs.isNotEmpty() -> {
val proposal = theirProposal
when {
proposal != null -> {
val agreement = Ledger.acceptExchange(proposal, mine, signer)
val myNew = mine.map { Ledger.applyExchange(it, agreement) }
val theirNew = theirs.map { Ledger.applyExchange(it, agreement) }
(myNew + theirNew).forEach { store.put(it) }
clearTable()
val done = Event.Done(gave = myNew, got = theirNew)
val accept = PeerMessage.Accept(me, deal, mine, agreement)
val home = closeIfHome(theirNew)
Step(send = listOf(accept) + home.send, events = listOf(done) + home.events)
}
// Mine signs first, once. The lowest id decides, so both agree who.
mine.minOf { it.id } < theirs.minOf { it.id } && myProposal == null -> {
val proposed = Ledger.proposeExchange(mine, theirs, signer)
myProposal = proposed
Step(send = listOf(PeerMessage.Accept(me, deal, mine, proposed)))
}
// Their side signs first, or mine already did; wait for it.
else -> Step()
}
}
// Only my side has anything: once they have said yes too, it is theirs.
mine.isNotEmpty() && theirAccepted == deal -> {
val given =
mine.map { Ledger.transfer(store.ious.value[it.id] ?: it, signer, peerKey).also(store::put) }
clearTable()
Step(send = listOf(PeerMessage.Give(given)), events = listOf(Event.Done(gave = given)))
}
// Only theirs: they sign it over, so wait for it to arrive.
else -> Step()
}
}
/** Their acceptance carried the agreement with both signatures on it: apply it here too. */
private fun complete(a: ExchangeAgreement, theirs: List<Iou>): Step {
val mineSide = a.side(myOffer.firstOrNull()?.id ?: "")
?: throw IllegalStateException("agreement is not about what you put down")
if (mineSide.holder != me) throw IllegalStateException("agreement does not name you as the holder")
val signature = mineSide.signature ?: throw IllegalStateException("agreement is unsigned on your side")
if (!Keys.verify(
me,
a.signingBytes(),
signature
)
) throw IllegalStateException("acceptance of a proposal you did not make")
if (mineSide.ious.map { it.iouId }.toSet() != myOffer.map { it.id }.toSet())
throw IllegalStateException("agreement is not about what you put down")
val their = theirs.ifEmpty { theirOffer }
val theirSide =
a.other(mineSide.ious.first().iouId) ?: throw IllegalStateException("agreement has no other side")
val byId = their.associateBy { it.id }
if (byId.size != theirSide.ious.size || theirSide.ious.any { ref ->
byId[ref.iouId]?.headHash() != ref.headHash
}) throw IllegalStateException("acceptance does not match the OwUs sent with it")
val myNew = myOffer.map {
Ledger.applyExchange(
store.ious.value[it.id] ?: throw IllegalStateException("your OwU is gone"), a
)
}
val theirNew = their.map { Ledger.applyExchange(it, a) }
(myNew + theirNew).forEach { store.put(it) }
clearTable()
val done = Event.Done(gave = myNew, got = theirNew)
// What came back may be promises of mine: the swap redeemed them.
val home = closeIfHome(theirNew)
return Step(send = home.send, events = listOf(done) + home.events)
}
/** Their half-signed swap: it must be about what is on the table, and signed by them. */
private fun checkProposal(a: ExchangeAgreement) {
if (theirOffer.isEmpty()) throw IllegalStateException("a swap with nothing on their side")
if (myOffer.isEmpty()) throw IllegalStateException("a swap with nothing on your side")
if (a.left.holder != peerKey) throw IllegalStateException("proposal is not theirs")
if (a.right.holder != me) throw IllegalStateException("proposal is not addressed to you")
val sig = a.left.signature ?: throw IllegalStateException("proposal unsigned")
if (!Keys.verify(peerKey, a.signingBytes(), sig)) throw IllegalStateException("proposal signature invalid")
if (!sameBundle(
a.left,
theirOffer
) { it }
) throw IllegalStateException("proposal is not about what they put down")
if (!sameBundle(a.right, myOffer) {
store.ious.value[it.id] ?: throw IllegalStateException("your OwU is gone")
})
throw IllegalStateException("proposal is not about what you put down")
}
/** The side names exactly these ious, each at the head we have for it. */
private fun sameBundle(side: net.helcel.owu.ledger.ExchangeSide, ious: List<Iou>, current: (Iou) -> Iou): Boolean {
if (side.ious.size != ious.size) return false
return ious.all { iou -> side.ref(iou.id)?.headHash == current(iou).headHash() }
}
private fun given(m: PeerMessage.Give): Step {
if (m.ious.isEmpty()) throw IllegalStateException("a gift of nothing")
val expected = theirOffer.map { it.id }.toSet()
m.ious.forEach { iou ->
val s = active(iou)
val head = iou.head as? Block.Transfer ?: throw IllegalStateException("a gift with no transfer on it")
if (head.transferor != peerKey || head.transferee != me || s.holder != me)
throw IllegalStateException("that gift is not from them to you")
if (expected.isNotEmpty() && iou.id !in expected) throw IllegalStateException("a gift of something else")
keep(iou)
}
clearTable()
// Promises of mine handed back to me: redeeming, with nothing to decide.
val home = closeIfHome(m.ious)
val got = Event.Done(got = m.ious)
return Step(send = home.send, events = listOf(got) + home.events)
}
/** An OwU back with its maker is spent - they were the only one who could
* be asked. However it arrived, it closes itself and the closed chain
* goes back as a receipt. */
private fun closeIfHome(ious: List<Iou>): Step {
val send = mutableListOf<PeerMessage>()
val events = mutableListOf<Event>()
ious.forEach { iou ->
val current = store.ious.value[iou.id] ?: iou
val state = Verifier.verify(current).stateOrNull ?: return@forEach
if (state.status != Status.ACTIVE || state.debtor != me || state.holder != me) return@forEach
val closed = Ledger.redeem(current, signer)
store.put(closed)
send += PeerMessage.Redeemed(closed)
events += Event.Redeemed(closed)
}
return Step(send = send, events = events)
}
/** What both sides say yes to: every OwU pinned to its current head. Both
* compute the same string, so a yes crossing a change is refused. */
private fun deal(): String? {
if (myOffer.isEmpty() && theirOffer.isEmpty()) return null
val sides = (myOffer + theirOffer)
.map { mapOf("iou_id" to it.id, "head_hash" to it.headHash()) }
.sortedBy { it["iou_id"] }
return Hash.sha256Hex(Canonical.bytes(sides))
}
/** The table changed: both yeses are void. */
private fun forget() {
myAccepted = null
theirAccepted = null
theirProposal = null
myProposal = null
}
/** Wipes the table after something happened on it: what was minted has moved. */
private fun clearTable() {
minted.clear()
myOffer = emptyList()
theirOffer = emptyList()
theirConflict = false
forget()
}
// --- redeem ------------------------------------------------------------
/** The receipt for an OwU handed back: closed by the one who made it. */
private fun redeemed(m: PeerMessage.Redeemed): Step {
val s = state(m.iou)
if (s.status != Status.REDEEMED || s.debtor != peerKey)
throw IllegalStateException("that is not a promise of theirs, closed by them")
keep(m.iou)
return Step(events = listOf(Event.Redeemed(m.iou)))
}
// --- helpers -----------------------------------------------------------
/** OwUs I hold and could put on the table. */
fun held(): List<Iou> = store.ious.value.values.filter {
Verifier.verify(it).stateOrNull?.let { s -> s.holder == me && s.status == Status.ACTIVE } == true
}
private fun state(iou: Iou): IouState = when (val v = Verifier.verify(iou)) {
is Verdict.Valid -> v.state
is Verdict.Invalid -> throw LedgerException("invalid chain at block ${v.sequence}: ${v.reason}")
}
private fun active(iou: Iou): IouState = state(iou).also {
if (it.status != Status.ACTIVE) throw LedgerException("OwU is ${it.status}")
}
/** Stores a chain received as the outcome of a handshake; anything but a clean fit is an error. */
private fun keep(iou: Iou) {
when (val r = store.merge(iou)) {
Merge.Added, Merge.Extended, Merge.Unchanged -> {}
is Merge.Fork -> throw IllegalStateException("conflicts with the copy you already have")
is Merge.Invalid -> throw IllegalStateException(r.reason)
}
}
companion object {
fun authBytes(nonce: String, key: String): ByteArray = "owu-auth-v1\n$nonce\n$key".toByteArray(Charsets.UTF_8)
}
}
/** Who a message is from, by its signatures or, unsigned, by what it states.
* Null when it names nobody. */
fun PeerMessage.counterparty(): String? = when (this) {
is PeerMessage.Table -> key
is PeerMessage.Accept -> key
is PeerMessage.Give -> (ious.firstOrNull()?.head as? Block.Transfer)?.transferor
is PeerMessage.Redeemed -> Verifier.verify(iou).stateOrNull?.debtor
else -> null
}
@@ -0,0 +1,186 @@
package net.helcel.owu.peer
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import net.helcel.owu.ble.Ble
import net.helcel.owu.ble.BlePermissions
import net.helcel.owu.ble.Link
import net.helcel.owu.store.Repo
/**
* The app's presence on the air, all of it [Link]: an advertisement to be
* found by, a connection to talk over. Only while the app is resumed
* ([resume]/[pause]). No switch: an ask has to arrive wherever you are.
*
* While resumed, also follows adapter on/off.
*/
// The link holds the *application* context; stop() lets go of it.
@SuppressLint("StaticFieldLeak")
object PeerManager {
data class State(
val active: Boolean = false,
/** Sessions by beacon hex, in order of appearance. */
val peers: Map<String, PeerSession> = emptyMap(),
val error: String? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
/** Adapter state, independent of [State.active]. */
enum class Radio { UNSUPPORTED, NO_PERMISSION, OFF, TURNING_ON, ON }
private val _radio = MutableStateFlow(Radio.OFF)
val radio: StateFlow<Radio> = _radio
private val _outcomes = MutableSharedFlow<Outcome>(extraBufferCapacity = 8)
/** Tables that finished, wherever they did. A redeem is answered from a
* prompt with no trade screen in sight, so the news must travel. */
val outcomes: SharedFlow<Outcome> = _outcomes
private var scope: CoroutineScope? = null
private var link: Link? = null
private var table: PeerTable? = null
private var listeningOn: Context? = null
private val main = Handler(Looper.getMainLooper())
private val adapterState = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) {
BluetoothAdapter.STATE_ON -> start(context)
// Tear down early; the link dies with the adapter.
BluetoothAdapter.STATE_TURNING_OFF, BluetoothAdapter.STATE_OFF -> stop()
}
refresh(context)
}
}
/** A verified session with the device whose key is [publicKey], if it is around. */
fun readySession(publicKey: String): PeerSession? = table?.readySession(publicKey)
/** Resumed: follow the adapter and start. */
fun resume(context: Context) {
val ctx = context.applicationContext
if (listeningOn == null) {
ContextCompat.registerReceiver(
ctx, adapterState, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED),
ContextCompat.RECEIVER_NOT_EXPORTED, // system broadcasts still arrive
)
listeningOn = ctx
}
refresh(ctx)
start(ctx)
}
/** Paused: stop and stop listening. */
fun pause() {
stopListening()
stop()
}
private fun stopListening() {
runCatching { listeningOn?.unregisterReceiver(adapterState) }
listeningOn = null
}
private fun refresh(context: Context) {
val adapter = BlePermissions.adapter(context)
_radio.value = when {
adapter == null -> Radio.UNSUPPORTED
!BlePermissions.granted(context) -> Radio.NO_PERMISSION
else -> when (adapter.state) {
BluetoothAdapter.STATE_ON -> Radio.ON
BluetoothAdapter.STATE_TURNING_ON -> Radio.TURNING_ON
else -> Radio.OFF
}
}
}
fun start(context: Context) {
if (_state.value.active) return
val ctx = context.applicationContext
if (!BlePermissions.granted(ctx)) return fail("Bluetooth permission not granted")
val adapter = BlePermissions.adapter(ctx) ?: return fail("No Bluetooth on this device")
if (!adapter.isEnabled) return fail("Bluetooth is off")
val s = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val me = Ble.beacon(Repo.me)
lateinit var l: Link
l = Link(ctx, adapter, me, onBroken = { reason -> main.post { standDown(l, reason) } })
val myName = Repo.myName(ctx)
val t = PeerTable(
s, { PeerEngine(Repo.store, Repo.signer, myName) }, ::send,
onOutcome = { _outcomes.tryEmit(it) })
scope = s
link = l
table = t
_state.value = State(active = true)
s.launch {
l.start()?.let { reason ->
main.post { standDown(l, reason) }
return@launch
}
launch { t.peers.collect { peers -> _state.update { it.copy(peers = peers) } } }
launch { l.presence.collect { t.heard(it) } }
launch { l.messages.collect { t.deliver(it) } }
launch {
while (isActive) {
delay(2_000)
t.prune()
}
}
}
}
fun stop() {
scope?.cancel()
scope = null
link?.stop()
link = null
table?.clear()
table = null
_state.value = State()
}
/** Opens the connection to [beacon]. True once messages can flow. */
suspend fun select(beacon: ByteArray): Boolean = link?.connect(beacon) ?: false
/** Done with that peer: hangs up. */
fun release(beacon: ByteArray) {
link?.disconnect(beacon)
}
private suspend fun send(beacon: ByteArray, bytes: ByteArray, dial: Boolean): Boolean =
link?.send(beacon, bytes, dial) ?: false
/** Link failed: reset so the next resume/adapter change/tap retries. */
private fun standDown(l: Link, reason: String) {
if (link !== l) return
stop()
fail(reason)
}
private fun fail(reason: String) = _state.update { it.copy(error = reason) }
}
@@ -0,0 +1,101 @@
package net.helcel.owu.peer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.ExchangeAgreement
import net.helcel.owu.ledger.Iou
/**
* What two devices say over a link. No reconciliation: each message is one
* step of an action a user took, carrying the whole chain it is about so the
* receiver verifies it from genesis.
*/
@Serializable
sealed class PeerMessage {
// --- handshake -----------------------------------------------------------
/** First message from each side: who I am and a challenge for you. */
@Serializable
@SerialName("HELLO")
data class Hello(val identity: IdentityCard, val nonce: String) : PeerMessage()
/** Proof that I hold the key in my HELLO: a signature over your nonce and my key. */
@Serializable
@SerialName("AUTH")
data class Auth(val signature: String) : PeerMessage()
// --- trade ---------------------------------------------------------------
// Two sides, a bundle or nothing on each. Both accept the same table and
// it happens: EXCHANGE if both brought something, TRANSFER if one did.
// Issuing, giving and swapping are all this.
/** The two things one side can ask of the other, and so the two it can refuse. */
@Serializable
enum class Asked { INVITE, TABLE }
/**
* No, not just now. Clears the refused table on both sides, so nothing
* sits refused; it exists so the asker is told instead of waiting for ever.
*/
@Serializable
@SerialName("DECLINE")
data class Decline(val key: String, val to: Asked) : PeerMessage()
/**
* I have opened a table and would like you at it. No ledger state, no
* answer: without it, wanting to trade reaches nobody not already looking.
*/
@Serializable
@SerialName("INVITE")
data class Invite(val key: String) : PeerMessage()
/**
* What I put on the table: OwUs I hold, or nothing. [key] is who I am,
* so a scanned code knows its counterparty even with nothing on it.
*/
@Serializable
@SerialName("TABLE")
data class Table(val key: String, val ious: List<Iou> = emptyList()) : PeerMessage()
/**
* I accept the table as I see it: my [ious] against yours, [deal]
* identifying it. For a swap, [agreement] is half-signed going first,
* countersigned answering; complete, both sides apply it.
*/
@Serializable
@SerialName("ACCEPT")
data class Accept(
val key: String,
val deal: String,
val ious: List<Iou> = emptyList(),
val agreement: ExchangeAgreement? = null,
) : PeerMessage()
/** My side of an accepted table, signed over to you: a gift, and the last message of it. */
@Serializable
@SerialName("GIVE")
data class Give(val ious: List<Iou>) : PeerMessage()
// --- redeem --------------------------------------------------------------
// Nothing waits: an OwU handed back to whoever owes it comes home closed.
/** Debtor to holder: their OwU back, with my signature voiding it. The receipt. */
@Serializable
@SerialName("REDEEMED")
data class Redeemed(val iou: Iou) : PeerMessage()
companion object {
val json = Json {
classDiscriminator = "type"
encodeDefaults = true
explicitNulls = false
ignoreUnknownKeys = true
}
fun encode(m: PeerMessage): ByteArray = json.encodeToString(serializer(), m).toByteArray(Charsets.UTF_8)
fun decode(bytes: ByteArray): PeerMessage = json.decodeFromString(serializer(), bytes.toString(Charsets.UTF_8))
}
}
@@ -0,0 +1,250 @@
package net.helcel.owu.peer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Verifier
import java.util.concurrent.atomic.AtomicInteger
/**
* One device as we know it: its [PeerEngine], the messages we owe it (sent in
* order, one at a time), and state for the screens. It exists while the peer
* is heard; the connection underneath is the [net.helcel.owu.ble.Link]'s.
*/
class PeerSession(
val beacon: ByteArray,
private val engine: PeerEngine,
/** Delivers one message; true once acked. With `dial`, connects first if need be. */
private val send: suspend (bytes: ByteArray, dial: Boolean) -> Boolean,
parent: CoroutineScope,
/** Called once for each table that finished, so something can say so out loud. */
private val onOutcome: (Outcome) -> Unit = {},
) {
enum class Phase { HANDSHAKE, READY, GONE }
data class State(
val phase: Phase = Phase.HANDSHAKE,
val peer: IdentityCard? = null,
val rssi: Int = 0,
val log: List<String> = emptyList(),
/** What the two of us have put on the table, and who has said yes. */
val table: PeerEngine.Table = PeerEngine.Table(),
/** What we are waiting on the peer for, if anything. */
val waiting: String? = null,
/** The last thing that did not reach them, so a screen can say so. */
val undelivered: String? = null,
/** They have asked us to the table and we have neither come nor declined. */
val invited: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job]))
private val mutex = Mutex()
/** Messages to send, each with the log line that belongs to it once it has arrived. */
private val outbox = Channel<Pair<PeerMessage, String?>>(Channel.UNLIMITED)
/** Messages queued and not yet on the air or acked. */
private val inFlight = AtomicInteger(0)
init {
scope.launch {
for ((m, line) in outbox) {
// A HELLO is said into the dark; anything else is a person's
// action and worth a connection. This is "redeem finds them".
if (send(PeerMessage.encode(m), m !is PeerMessage.Hello)) {
if (line != null) log(line)
} else if (m !is PeerMessage.Hello) {
// An unanswered HELLO is ordinary; we say it again presently.
// A user's action that did not arrive is not, so say so.
val what = line ?: m::class.simpleName.orEmpty()
_state.update {
it.copy(log = it.log + "Not delivered: $what", waiting = null, undelivered = what)
}
}
inFlight.decrementAndGet()
}
}
perform { engine.start() }
// Until verified, say hello again: the first may have gone out before
// the peer was listening.
scope.launch {
while (_state.value.phase == Phase.HANDSHAKE) {
delay(REINTRODUCE_MS)
if (_state.value.phase == Phase.HANDSHAKE && inFlight.get() == 0) perform { engine.reintroduce() }
}
}
}
/** A message from this peer, off the air. */
fun deliver(bytes: ByteArray) = perform { engine.onMessage(PeerMessage.decode(bytes)) }
fun heard(rssi: Int) = _state.update { it.copy(rssi = rssi) }
// --- user actions ------------------------------------------------------
/** My side of the table: ious I hold plus one minted per template. Empty takes it off. */
fun put(held: List<Iou> = emptyList(), mint: List<Metadata> = emptyList()) = perform { engine.put(held, mint) }
/** Mint one OwU from a template and put that on the table by itself. */
fun putNew(metadata: Metadata) = perform { engine.putNew(metadata) }
/** Say yes to the table as it stands. */
fun accept() = perform(waiting = "them to accept") { engine.accept() }
/** Ask them to the table just opened; until this they had no way of knowing. */
fun invite() = perform { engine.invite() }
/** Their invitation has been answered, one way or the other. */
fun inviteAnswered() = _state.update { it.copy(invited = false) }
/** No, not just now - and say so, rather than leaving them waiting. */
fun decline(to: PeerMessage.Asked) = perform { engine.decline(to) }
/** Put [ious] down and say yes in one move, under one lock so the yes
* cannot overtake the ious it is about. */
fun offer(ious: List<Iou>) = perform(waiting = "them to accept") {
val down = engine.put(held = ious)
val yes = engine.accept()
// One message, not two: the acceptance carries its own ious, so a
// TABLE first only adds something that can go missing. Its events
// still fire, so this side's screens see what was put down.
PeerEngine.Step(send = yes.send, events = down.events + yes.events)
}
/** Give up on an answer that is not coming; whatever was pending stays valid if it does. */
fun stopWaiting() = _state.update { it.copy(waiting = null) }
/** The peer has not been heard for a while. */
fun gone() {
// Anything minted for a table that never happened goes with them.
runCatching { engine.abandon() }
scope.cancel()
outbox.close()
_state.update { it.copy(phase = Phase.GONE, waiting = null) }
}
// --- plumbing ----------------------------------------------------------
/** Runs one engine step off the calling thread. [line] is logged once the
* last message is acked, so "Sent" means sent. */
private fun perform(line: String? = null, waiting: String? = null, block: () -> PeerEngine.Step) {
scope.launch {
try {
mutex.withLock {
// A fresh attempt: whatever failed last time is history.
_state.update { it.copy(undelivered = null) }
val step = block()
step.send.forEachIndexed { i, m ->
inFlight.incrementAndGet()
outbox.send(m to line.takeIf { i == step.send.lastIndex })
}
for (e in step.events) handle(e)
announce(step.events)
if (step.send.isEmpty() && line != null) log(line)
// Nothing to wait for if the step already finished it.
if (waiting != null && step.events.none { it is PeerEngine.Event.Done }) {
_state.update { it.copy(waiting = waiting) }
}
}
} catch (e: Exception) {
log("Error: ${e.message}")
}
}
}
private fun handle(e: PeerEngine.Event) {
when (e) {
// No log line: the tick beside their key says it where it matters.
is PeerEngine.Event.PeerIdentified -> _state.update {
it.copy(phase = Phase.READY, peer = e.card)
}
PeerEngine.Event.Restarted -> _state.update {
State(
phase = Phase.HANDSHAKE,
peer = it.peer,
rssi = it.rssi,
log = it.log + "Peer came back; verifying again"
)
}
is PeerEngine.Event.Tabled -> _state.update { it.copy(table = e.table) }
is PeerEngine.Event.Done -> {
_state.update { it.copy(table = PeerEngine.Table()) }
val gave = summarise(e.gave)
val got = summarise(e.got)
answered(
when {
gave != null && got != null -> "Traded $gave for $got"
gave != null -> "Gave $gave"
got != null -> "Got $got"
else -> "Nothing changed hands"
}
)
}
is PeerEngine.Event.Redeemed -> {
// A redemption ends the table it was on, on both sides.
_state.update { it.copy(table = PeerEngine.Table()) }
answered("${e.iou.metadata.title} redeemed")
}
is PeerEngine.Event.Invited -> _state.update { it.copy(invited = true) }
// Nothing is being waited for any more; their answer was no.
is PeerEngine.Event.Declined -> _state.update { it.copy(waiting = null) }
is PeerEngine.Event.Failed -> log("Error: ${e.reason}")
}
}
/**
* One announcement per step, or none. An OwU going home is not announced
* as given but when its closed receipt returns, which is when it happened;
* on their side gift and receipt are one step. Either way, named once.
*/
private fun announce(events: List<PeerEngine.Event>) {
val done = events.filterIsInstance<PeerEngine.Event.Done>()
val redeemed = events.filterIsInstance<PeerEngine.Event.Redeemed>().map { it.iou }
val closed = redeemed.map { it.id }.toSet()
val peer = _state.value.peer?.key
val outcome = Outcome(
peer = peer,
gave = done.flatMap { it.gave }.filter { it.id !in closed && !goesHome(it, peer) },
got = done.flatMap { it.got }.filter { it.id !in closed },
redeemed = redeemed,
declined = events.filterIsInstance<PeerEngine.Event.Declined>().firstOrNull()?.to,
)
if (!outcome.isEmpty) onOutcome(outcome)
}
/** A promise of theirs, on its way back to them: a redemption in progress. */
private fun goesHome(iou: Iou, peer: String?): Boolean =
peer != null && Verifier.verify(iou).stateOrNull?.debtor == peer
private fun log(line: String) = _state.update { it.copy(log = it.log + line) }
private fun answered(line: String) = _state.update { it.copy(log = it.log + line, waiting = null) }
companion object {
private const val REINTRODUCE_MS = 6_000L
}
}
/** A bundle in one line: "3 × 1 Beer, 1 Heavy Hug", not the title three times. */
private fun summarise(ious: List<net.helcel.owu.ledger.Iou>): String? {
if (ious.isEmpty()) return null
return ious.groupingBy { it.metadata.title }.eachCount().entries
.joinToString(", ") { (title, n) -> if (n > 1) "$n × $title" else title }
}
@@ -0,0 +1,101 @@
package net.helcel.owu.peer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import net.helcel.owu.ble.Ble
import net.helcel.owu.ble.Heard
import net.helcel.owu.crypto.Hash
/**
* The devices around us, one [PeerSession] each, keyed by beacon. Nothing
* Android in it. A session starts on the first presence heard, or on a HELLO,
* which names its sender's key.
*/
class PeerTable(
private val scope: CoroutineScope,
private val newEngine: () -> PeerEngine,
/** Delivers bytes to the device with this beacon; true once acked. With `dial`, connects first if need be. */
private val send: suspend (beacon: ByteArray, bytes: ByteArray, dial: Boolean) -> Boolean,
private val clock: () -> Long = System::currentTimeMillis,
/** Passed to every session: what a finished table amounted to. */
private val onOutcome: (Outcome) -> Unit = {},
) {
private val _peers = MutableStateFlow<Map<String, PeerSession>>(emptyMap())
/** Sessions by beacon hex, in order of appearance. */
val peers: StateFlow<Map<String, PeerSession>> = _peers
private val lastHeard = mutableMapOf<String, Long>()
/** Two sessions for one peer means two handshakes, which each side reads
* as the other restarting. So looking and creating are one step. */
private val lock = Any()
/** A verified session with the device whose key is [publicKey], if it is around. */
fun readySession(publicKey: String): PeerSession? =
_peers.value[Ble.beaconHex(publicKey)]?.takeIf { it.state.value.phase == PeerSession.Phase.READY }
fun heard(seen: Heard.Presence): PeerSession = synchronized(lock) {
val key = Hash.hex(seen.beacon)
lastHeard[key] = clock()
val existing = _peers.value[key]
if (existing != null && existing.state.value.phase != PeerSession.Phase.GONE) {
existing.heard(seen.rssi)
return existing
}
val beacon = seen.beacon
val session = PeerSession(beacon, newEngine(), { bytes, dial -> send(beacon, bytes, dial) }, scope, onOutcome)
session.heard(seen.rssi)
_peers.update { it + (key to session) }
return session
}
/** Hands a message to its sender's session, making one if their HELLO introduces them. */
fun deliver(inbound: Heard.Message) = synchronized(lock) {
(sessionFor(inbound.from) ?: adopt(inbound))?.deliver(inbound.bytes)
}
/** From a device not yet heard: a HELLO names its key, which is enough. */
private fun adopt(inbound: Heard.Message): PeerSession? {
val hello = runCatching { PeerMessage.decode(inbound.bytes) }.getOrNull() as? PeerMessage.Hello ?: return null
val beacon = runCatching { Ble.beacon(hello.identity.key) }.getOrNull() ?: return null
if (!beacon.contentEquals(inbound.from)) return null
return heard(Heard.Presence(beacon, 0))
}
private fun sessionFor(beacon: ByteArray): PeerSession? =
_peers.value.values.firstOrNull { it.beacon.contentEquals(beacon) }
/**
* Drops sessions unheard for a while. Advertising is the *only* evidence
* of presence: an open channel is not, since a phone that sleeps or dies
* often leaves one behind with no disconnect ever arriving, and a session
* propped up by that never goes. Tried it; worse than the wait it saved.
*/
fun prune() {
synchronized(lock) {
val now = clock()
val stale = _peers.value.filter { (k, p) ->
p.state.value.phase == PeerSession.Phase.GONE || now - (lastHeard[k] ?: 0L) > GONE_MS
}
if (stale.isEmpty()) return
stale.values.forEach { it.gone() }
_peers.update { it - stale.keys }
}
}
/** Ends every session. */
fun clear() = synchronized(lock) {
_peers.value.values.forEach { it.gone() }
lastHeard.clear()
_peers.value = emptyMap()
}
companion object {
/** How long somebody stays "here" unheard. A live peer advertises many
* times a second, so this is many misses, not a close call. Short,
* because the dot and the trade button act on it. */
const val GONE_MS = 12_000L
}
}
@@ -0,0 +1,114 @@
package net.helcel.owu.store
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouJson
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/** What went wrong opening a backup, in terms the screen can repeat. */
class BackupException(message: String) : Exception(message)
/**
* Everything this phone is in one file: identity, OwUs, templates, contacts.
*
* Encrypted under a passphrase, because it holds a private key - whoever opens
* it can *be* you. AES-GCM under a PBKDF2 key, parameters in the clear so an
* older file still opens when they change.
*/
object Backup {
@Serializable
data class Contents(
/** The identity's private half, PKCS#8 DER, base64. */
@SerialName("private_key") val privateKey: String,
/** Its public half, X.509 DER, base64 - kept so the pair can be checked. */
@SerialName("public_key") val publicKey: String,
val name: String = "",
val ious: List<Iou> = emptyList(),
val templates: List<Template> = emptyList(),
val contacts: List<Contact> = emptyList(),
)
@Serializable
private data class Envelope(
val v: Int = VERSION,
val app: String = "owu",
val kdf: String = "PBKDF2WithHmacSHA256",
val rounds: Int = ROUNDS,
val salt: String,
val nonce: String,
val data: String,
)
const val VERSION = 1
private const val ROUNDS = 210_000
private const val KEY_BITS = 256
private const val TAG_BITS = 128
private const val SALT = 16
private const val NONCE = 12
/** The bytes to write to the file the user chose. */
fun seal(contents: Contents, passphrase: CharArray): ByteArray {
val random = SecureRandom()
val salt = ByteArray(SALT).also(random::nextBytes)
val nonce = ByteArray(NONCE).also(random::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, key(passphrase, salt, ROUNDS), GCMParameterSpec(TAG_BITS, nonce))
}
val plain = IouJson.json.encodeToString(Contents.serializer(), contents).toByteArray(Charsets.UTF_8)
val envelope = Envelope(
salt = base64(salt),
nonce = base64(nonce),
data = base64(cipher.doFinal(plain)),
)
return IouJson.json.encodeToString(Envelope.serializer(), envelope).toByteArray(Charsets.UTF_8)
}
/** What is in a backup file, or [BackupException] saying why not. */
fun open(bytes: ByteArray, passphrase: CharArray): Contents {
val envelope = try {
IouJson.json.decodeFromString(Envelope.serializer(), bytes.toString(Charsets.UTF_8))
} catch (e: Exception) {
throw BackupException("that is not an OwU backup")
}
if (envelope.app != "owu" || envelope.v > VERSION) throw BackupException("that backup is from a newer OwU")
val plain = try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(
Cipher.DECRYPT_MODE,
key(passphrase, unbase64(envelope.salt), envelope.rounds),
GCMParameterSpec(TAG_BITS, unbase64(envelope.nonce)),
)
}
cipher.doFinal(unbase64(envelope.data))
} catch (e: AEADBadTagException) {
// One message for both: a wrong passphrase and a changed byte are
// the same event to AES-GCM, and telling them apart helps nobody.
throw BackupException("wrong passphrase, or the file has been damaged")
} catch (e: Exception) {
throw BackupException("that backup could not be read")
}
return try {
IouJson.json.decodeFromString(Contents.serializer(), plain.toString(Charsets.UTF_8))
} catch (e: Exception) {
throw BackupException("that backup could not be read")
}
}
private fun key(passphrase: CharArray, salt: ByteArray, rounds: Int) = SecretKeySpec(
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(PBEKeySpec(passphrase, salt, rounds, KEY_BITS)).encoded,
"AES",
)
private fun base64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)
private fun unbase64(text: String): ByteArray = Base64.getDecoder().decode(text)
}
@@ -0,0 +1,193 @@
package net.helcel.owu.store
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouJson
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import java.io.File
/** A promise kept ready to make: no chain, no signature. Handing one over
* mints a fresh OwU - new id, signed there and then - so one "1 Beer" here
* becomes as many beers as you hand out. Stays until deleted. */
@Serializable
data class Template(
val id: String,
val metadata: Metadata,
@SerialName("created_at") val createdAt: Long,
)
/** Somebody you have met and named. The name is yours; the key is what makes
* it worth anything. With it, an OwU arriving through a third party still
* reads as "owed by Alice": the signature proves the key, this says whose. */
@Serializable
data class Contact(
@SerialName("pub_key") val publicKey: String,
val name: String,
)
/** A key that introduced itself on the air, with the name it gave. Not a
* [Contact]: nobody vouched for it, and it only makes a screen readable. */
@Serializable
data class Met(
@SerialName("pub_key") val publicKey: String,
val name: String,
)
/** What happened when a chain arrived from outside. */
sealed class Merge {
object Added : Merge()
object Extended : Merge()
object Unchanged : Merge()
/** Same OwU, different history: neither side is a prefix of the other. */
data class Fork(val existing: Iou) : Merge()
data class Invalid(val reason: String) : Merge()
}
/** Everything the app keeps: a JSON file per OwU and per template, plus the
* address book. Small enough to live in memory, written straight to disk. */
class IouStore(root: File) {
private val iouDir = File(root, "ious").apply { mkdirs() }
private val templateDir = File(root, "templates").apply { mkdirs() }
private val contactsFile = File(root, "contacts.json")
private val metFile = File(root, "met.json")
private val _ious = MutableStateFlow(loadDir(iouDir, Iou.serializer()).associateBy { it.id })
val ious: StateFlow<Map<String, Iou>> = _ious
private val _templates = MutableStateFlow(loadDir(templateDir, Template.serializer()).associateBy { it.id })
val templates: StateFlow<Map<String, Template>> = _templates
private val _contacts = MutableStateFlow(loadContacts())
val contacts: StateFlow<List<Contact>> = _contacts
private val _met = MutableStateFlow(loadList(metFile, Met.serializer()))
/** Names keys have given for themselves, for reading screens by. */
val met: StateFlow<List<Met>> = _met
// --- OwUs -------------------------------------------------------------
/** Stores a chain we made ourselves, which [net.helcel.owu.ledger.Ledger] has already verified. */
fun put(iou: Iou) {
write(File(iouDir, "${iou.id}.json"), IouJson.encode(iou))
_ious.update { it + (iou.id to iou) }
}
fun remove(id: String) {
File(iouDir, "$id.json").delete()
_ious.update { it - id }
}
/** Takes a chain from a peer. Only a valid extension of what we have, or
* one new to us, is stored; a fork is reported, not resolved, since only
* the debtor can decide it. */
fun merge(incoming: Iou): Merge = compare(incoming).also { verdict ->
when (verdict) {
Merge.Added, Merge.Extended -> put(incoming)
else -> {}
}
}
/** What [merge] would make of [incoming], without keeping it: an OwU looked
* over before anyone agrees. A [Merge.Fork] means it and the copy you hold
* each carry signatures the other does not - someone signed twice. */
fun compare(incoming: Iou): Merge {
val verdict = Verifier.verify(incoming)
if (verdict is Verdict.Invalid) return Merge.Invalid("block ${verdict.sequence}: ${verdict.reason}")
val existing = _ious.value[incoming.id] ?: return Merge.Added
val common = minOf(existing.chain.size, incoming.chain.size)
if (existing.metadata != incoming.metadata || existing.chain.take(common) != incoming.chain.take(common)) {
return Merge.Fork(existing)
}
if (incoming.chain.size <= existing.chain.size) return Merge.Unchanged
return Merge.Extended
}
// --- templates ----------------------------------------------------------
fun putTemplate(template: Template) {
write(File(templateDir, "${template.id}.json"), json.encodeToString(Template.serializer(), template))
_templates.update { it + (template.id to template) }
}
fun removeTemplate(id: String) {
File(templateDir, "$id.json").delete()
_templates.update { it - id }
}
// --- contacts ----------------------------------------------------------
fun nameFor(publicKey: String): String? = _contacts.value.firstOrNull { it.publicKey == publicKey }?.name
/** The name a key gave for itself, if we have heard it introduce itself. */
fun metName(publicKey: String): String? = _met.value.firstOrNull { it.publicKey == publicKey }?.name
/** Remembers what a key calls itself. Never touches what you have named it. */
fun putMet(publicKey: String, name: String) {
val clean = name.trim()
if (clean.isEmpty() || metName(publicKey) == clean) return
val list = _met.value.filter { it.publicKey != publicKey } + Met(publicKey, clean)
write(metFile, json.encodeToString(ListSerializer(Met.serializer()), list))
_met.value = list
}
fun putContact(contact: Contact) = saveContacts(
_contacts.value.filter { it.publicKey != contact.publicKey } + contact
)
fun removeContact(publicKey: String) = saveContacts(_contacts.value.filter { it.publicKey != publicKey })
private fun saveContacts(list: List<Contact>) {
write(contactsFile, json.encodeToString(ListSerializer(Contact.serializer()), list))
_contacts.value = list
}
private fun loadContacts(): List<Contact> = loadList(contactsFile, Contact.serializer())
private fun <T> loadList(file: File, serializer: KSerializer<T>): List<T> =
if (!file.exists()) emptyList() else try {
json.decodeFromString(ListSerializer(serializer), file.readText())
} catch (e: Exception) {
Log.w(TAG, "unreadable ${file.name}", e)
emptyList()
}
// --- files -------------------------------------------------------------
private fun <T> loadDir(dir: File, serializer: KSerializer<T>): List<T> =
(dir.listFiles { f -> f.extension == "json" } ?: emptyArray()).mapNotNull { f ->
try {
json.decodeFromString(serializer, f.readText())
} catch (e: Exception) {
// Leave it in place: a corrupt file is worth more to a
// developer than a clean directory is to the app.
Log.w(TAG, "skipping unreadable ${f.name}", e)
null
}
}
/** Write-then-rename, so a crash mid-write cannot leave a half file behind. */
private fun write(target: File, text: String) {
val tmp = File(target.parentFile, "${target.name}.tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
companion object {
private const val TAG = "IouStore"
private val json get() = IouJson.json
}
}
@@ -0,0 +1,67 @@
package net.helcel.owu.store
import android.content.Context
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import net.helcel.owu.BuildConfig
import net.helcel.owu.crypto.Keys
import net.helcel.owu.crypto.Identity
import net.helcel.owu.demo.Demo
import net.helcel.owu.helper.defaultPreferences
/**
* The app's one identity and one store, opened once per process. [init] does
* the Keystore work and so belongs off the main thread.
*/
object Repo {
lateinit var store: IouStore
private set
lateinit var signer: Identity
private set
private val _ready = MutableStateFlow(false)
val ready: StateFlow<Boolean> = _ready
val me: String get() = signer.publicKey
@Synchronized
fun init(context: Context) {
if (_ready.value) return
store = IouStore(context.filesDir)
signer = Identity.load(context)
if (BuildConfig.DEBUG) {
Log.i("owu", "identity ${signer.publicKey}")
// A debug install starts with something to look at, once. R8 drops
// this and Demo with it from a release.
if (Demo.wanted(store)) Demo.fill(store, signer)
}
_ready.value = true
}
/** How a key is shown: you, your name for it, its own, or its fingerprint. */
fun nameOf(publicKey: String): String = when {
publicKey == me -> "You"
else -> store.nameFor(publicKey) ?: store.metName(publicKey) ?: Keys.fingerprint(publicKey)
}
/** Whether you have named this key. An OwU owed by a contact reads as that
* person's promise however it reached you; one owed by a key you never met
* is a stranger's, whatever the person handing it over calls them. */
fun knows(publicKey: String): Boolean = publicKey == me || store.nameFor(publicKey) != null
/** Becomes the identity from a backup. Everything signed from here on is that one. */
@Synchronized
fun adopt(identity: Identity) {
signer = identity
}
fun myName(context: Context): String = defaultPreferences(context).getString(KEY_NAME, "") ?: ""
fun setMyName(context: Context, name: String) {
defaultPreferences(context).edit { putString(KEY_NAME, name) }
}
private const val KEY_NAME = "my_name"
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Nothing but colour: the launcher gives the icon its shape. -->
<path
android:fillColor="@color/blue"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
The face the name is: O w U - one eye, a mouth, the other eye. Both eyes
stand on the same baseline (y = 62) and reach the same height (y = 42);
the mouth is centred between them on the same band.
-->
<path
android:pathData="M32,52m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white" />
<path
android:pathData="M47,50l3.5,10l3.5,-8l3.5,8l3.5,-10"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M68,44v8a8,8 0,0 0,16 0v-8"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- The same face, for themed icons: the system supplies the colour. -->
<path
android:pathData="M32,52m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white" />
<path
android:pathData="M47,50l3.5,10l3.5,-8l3.5,8l3.5,-10"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M68,44v8a8,8 0,0 0,16 0v-8"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Owu" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Only seen for the instant before the first frame. -->
<item name="android:windowBackground">@color/darkgray</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="darkgray">#FF0C1D2E</color>
<color name="white">#FFF0F3F7</color>
<color name="blue">#0d5cab</color>
</resources>
+178
View File
@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="action_settings">Settings</string>
<string name="action_new">New OwU</string>
<string name="bluetooth_on">Bluetooth on: nearby phones can reach you</string>
<string name="bluetooth_starting">Bluetooth starting…</string>
<string name="bluetooth_off">Bluetooth off: tap to turn it on</string>
<string name="bluetooth_unsupported">This device has no Bluetooth</string>
<string name="action_save">Save</string>
<string name="action_save_and_offer">Save and put it on the table</string>
<string name="action_redeem">Redeem</string>
<string name="redeem_ask">%1$s wants to redeem</string>
<string name="redeem_later">Not now</string>
<string name="redeem_waiting">Waiting for %1$s to accept…</string>
<string name="redeem_unreachable">Could not reach %1$s. They may be away; the request is still out.</string>
<string name="redeem_stop">Stop asking</string>
<string name="redeem_asked">Asked %1$s to redeem it</string>
<string name="redeem_looking">Looking for %1$s…</string>
<string name="done_redeemed">%1$s redeemed</string>
<string name="done_traded">Traded %1$s for %2$s</string>
<string name="done_gave">Gave %1$s</string>
<string name="done_got">Got %1$s</string>
<string name="done_with">with %1$s</string>
<string name="done_declined_trade">Not up for a trade just now</string>
<string name="done_declined_ask">Not redeeming it just now</string>
<string name="action_accept">Accept</string>
<string name="action_confirm">Confirm</string>
<string name="action_copy_key">Copy key</string>
<string name="template_new">New template</string>
<string name="edit_template">Edit template</string>
<string name="key_theme">App theme</string>
<string name="system">System</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="about">About</string>
<string name="pref_category_notes">OwUs</string>
<string name="pref_category_data">Backup</string>
<string name="identity_title">Profile</string>
<string name="field_your_name">Your name</string>
<string name="contacts">Contacts</string>
<string name="no_contacts">No contacts yet. Scan someone\'s QR code to add them.</string>
<string name="contact_add">Add contact</string>
<string name="contact_name_this">Name this person</string>
<string name="contact_rename">Rename</string>
<string name="contact_name_hint">Their key is proven; the name is yours to choose. OwUs they owe will read as this name, even when they reach you through someone else.</string>
<string name="contact_share">Share as QR</string>
<string name="contact_away">Not around</string>
<string name="contact_here">Here now</string>
<string name="contact_trade">Trade</string>
<string name="trade_ask">%1$s wants to trade</string>
<string name="trade_accept">Open the table</string>
<string name="contact_add_manually">Enter key</string>
<string name="contact_scan">Scan QR</string>
<string name="contact_from_clipboard">From clipboard</string>
<string name="contact_unreadable">That is not an OwU identity.</string>
<string name="contact_is_you">That is your own key.</string>
<string name="scan_prompt">Scan an OwU identity code</string>
<string name="field_key">Public key</string>
<string name="copied">Copied</string>
<string name="list_empty">No OwUs yet.\nWhat you hold and what you have handed over shows up here. Tap + to write a promise you are ready to make.</string>
<string name="section_templates">Templates</string>
<string name="section_other">Given away</string>
<string name="redeemed_title">Redeemed</string>
<string name="owed_title">Owed</string>
<string name="templates_empty">No templates yet. Write a promise you expect to make more than once - a beer, a lift - and it waits here until you hand one out.</string>
<string name="owed_empty">You owe nobody anything. Promises of yours that somebody else is holding show up here.</string>
<string name="redeemed_empty">Nothing has been redeemed yet. An OwU that comes home and is closed ends up here.</string>
<string name="from_x">from %1$s</string>
<string name="to_x">to %1$s</string>
<string name="owed_by_x">owed by %1$s</string>
<string name="in_your_contacts">key you hold</string>
<string name="own_promise">their own promise</string>
<string name="own_promise_yours">your own promise</string>
<string name="own_promise_mine">your own promise, coming back</string>
<string name="yours_to_give">not given yet</string>
<string name="closed_by_x">closed by %1$s</string>
<string name="back_with_x">back with %1$s</string>
<string name="untitled">Untitled</string>
<string name="template_ready">Template</string>
<plurals name="template_given">
<item quantity="one">given once</item>
<item quantity="other">given %1$d times</item>
</plurals>
<string name="status_active">Active</string>
<string name="status_redeemed">Redeemed</string>
<string name="field_title">What is owed</string>
<string name="field_description">Details (optional)</string>
<string name="field_description_hint">Terms, the occasion, anything worth writing down</string>
<string name="field_status">Status</string>
<string name="field_geoloc">Place</string>
<string name="field_geoloc_desc">Where it is meant to be redeemed. Never a bar.</string>
<string name="field_geo_label">Place name</string>
<string name="field_lat">Latitude</string>
<string name="field_lon">Longitude</string>
<string name="field_radius">Radius (m)</string>
<string name="geo_use_current">Use my location</string>
<string name="geo_locating">Locating…</string>
<string name="geo_unavailable">Could not get your location.</string>
<string name="geo_permission_denied">Location permission is needed for that.</string>
<string name="field_window">Valid</string>
<string name="field_window_desc">When it is meant to be redeemed. Never a bar.</string>
<string name="field_not_before">Not before</string>
<string name="field_not_after">Not after</string>
<string name="window_from">from %1$s</string>
<string name="window_until">until %1$s</string>
<string name="window_expired">expired</string>
<string name="issue_bad_window">The window closes before it opens.</string>
<string name="role_debtor">Owed by</string>
<string name="role_holder">Held by</string>
<string name="issue_need_title">Say what is owed.</string>
<string name="issue_bad_geo">Check the place coordinates and radius.</string>
<string name="history">History</string>
<string name="hist_issued">%1$s wrote it</string>
<string name="hist_transferred">%1$s handed it to %2$s</string>
<string name="hist_closed">%1$s closed it</string>
<string name="hist_exchanged">%1$s exchanged it with %2$s</string>
<string name="hist_redeemed">%1$s redeemed it</string>
<string name="invalid_chain">This OwU fails verification at block %1$d: %2$s</string>
<string name="delete_iou_confirm">Remove this OwU from this device? Others who hold a copy are not affected.</string>
<string name="backup_export">Export</string>
<string name="backup_restore">Restore</string>
<string name="backup_passphrase">Passphrase</string>
<string name="backup_passphrase_again">Passphrase again</string>
<string name="backup_passphrase_hint">This file is your identity. Nothing opens it without the passphrase.</string>
<string name="backup_passphrase_ask">The passphrase this backup was saved with.</string>
<string name="backup_written">Backup saved.</string>
<string name="backup_restored">Restored.</string>
<string name="backup_failed">That did not work.</string>
<string name="backup_identity_title">Become this identity?</string>
<string name="backup_identity_text">Your OwUs are back. The backup also holds the identity %1$s. Taking it on replaces this phone\u2019s own: promises made under that one could no longer be closed here.</string>
<string name="backup_identity_take">Become it</string>
<string name="backup_identity_keep">Keep mine</string>
<string name="backup_identity_taken">This phone is that identity now.</string>
<string name="app_is_foss">iOweU is free and open source software, licensed under the GNU General Public License (version 3 or later)</string>
<string name="app_repo_uri" translatable="false">https://github.com/helcel-net/iOweU</string>
<string name="app_repo">Project repository: %1$s\nFeel free to report issues or contribute.</string>
<string name="peer_unknown">Someone</string>
<string name="peer_verifying">Verifying identity…</string>
<string name="peer_verified">Verified</string>
<string name="peer_waiting">Waiting for %1$s…</string>
<string name="peer_stop_waiting">Stop waiting</string>
<string name="peer_gone">Out of range</string>
<string name="table_title">On the table</string>
<string name="table_you">You</string>
<string name="table_nothing">nothing</string>
<string name="table_put">Add</string>
<string name="table_change">Change</string>
<string name="table_pick_mine">Put on the table</string>
<plurals name="table_put_n">
<item quantity="one">Put %1$d down</item>
<item quantity="other">Put %1$d down</item>
</plurals>
<string name="more">One more</string>
<string name="fewer">One fewer</string>
<string name="table_take_off">Take mine off</string>
<string name="table_new_promise">Write a new promise…</string>
<string name="table_from_presets">From a template</string>
<string name="table_from_held">Or pass on an OwU you hold</string>
<string name="table_conflict">This OwU contradicts the copy you already hold: it has been signed over twice. Do not accept it.</string>
<string name="peer_name_clash">Calls itself %1$s, but this is not the key you know %1$s by.</string>
<string name="table_waiting_for">Waiting for %1$s to confirm.</string>
<string name="issue_onto_table">This promise goes onto the table with %1$s.</string>
<string name="peer_connecting">Connecting…</string>
<string name="peer_no_link">Could not connect. Move closer and try again.</string>
<string name="name">Name</string>
<string name="delete">Delete</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
</resources>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The bars are transparent and the app paints behind them, so the strip
above the top bar is the top bar's own colour whatever the theme makes
that. Naming a colour here cannot work: the app bar is Material You's
dynamic primary, which is chosen on the device from the user's
wallpaper, and no value written in this file will ever match it.
(From API 35 the platform ignores these colours anyway.)
-->
<style name="Theme.Owu" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Only seen for the instant before the first frame. -->
<item name="android:windowBackground">@color/white</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,49 @@
package net.helcel.owu.ble
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Framing on a connection: whole messages out of whatever arrives. */
class FramesTest {
private val me = ByteArray(8) { (it + 1).toByte() }
private val frames = Frames()
private fun bytes(n: Int) = ByteArray(n) { (it * 7).toByte() }
@Test
fun `a message arrives whole however it is cut up`() {
val message = bytes(1000)
val frame = frames.frame(me, message)
val reader = Frames()
val chunks = frames.chunks(frame, 20)
assertTrue(chunks.size > 40, "cut into ${chunks.size}")
val got = chunks.flatMap { reader.feed(it) }
assertEquals(1, got.size)
assertContentEquals(me, got[0].first)
assertContentEquals(message, got[0].second)
}
@Test
fun `several messages in one arrival come out in order`() {
val reader = Frames()
val one = frames.frame(me, "one".toByteArray())
val two = frames.frame(me, "two".toByteArray())
val got = reader.feed(one + two)
assertEquals(listOf("one", "two"), got.map { String(it.second) })
}
@Test
fun `a partial message waits, and an absurd length drops the stream`() {
val reader = Frames()
val frame = frames.frame(me, bytes(100))
assertTrue(reader.feed(frame.copyOfRange(0, 40)).isEmpty(), "not whole yet")
assertEquals(1, reader.feed(frame.copyOfRange(40, frame.size)).size)
// A length nothing could produce: the buffer is dropped, not grown.
assertTrue(reader.feed(byteArrayOf(0x7f, -1, -1, -1)).isEmpty())
val after = reader.feed(frames.frame(me, "after".toByteArray()))
assertEquals(listOf("after"), after.map { String(it.second) })
}
}
@@ -0,0 +1,44 @@
package net.helcel.owu.crypto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class CanonicalTest {
@Test
fun `keys are sorted and nulls dropped`() {
val out = Canonical.encode(mapOf("b" to 1, "a" to "x", "c" to null))
assertEquals("""{"a":"x","b":1}""", out)
}
@Test
fun `nested objects and lists`() {
val out = Canonical.encode(mapOf("z" to listOf(1, "two", mapOf("k" to true)), "a" to mapOf("y" to 2L, "x" to 1)))
assertEquals("""{"a":{"x":1,"y":2},"z":[1,"two",{"k":true}]}""", out)
}
@Test
fun `strings are escaped`() {
val out = Canonical.encode("q\"b\\n\nt\tc\u0001")
assertEquals(""""q\"b\\n\nt\tc\u0001"""", out)
}
@Test
fun `key order is by code unit not locale`() {
val out = Canonical.encode(mapOf("é" to 1, "z" to 2, "A" to 3))
assertEquals("""{"A":3,"z":2,"é":1}""", out)
}
@Test
fun `floating point is refused`() {
assertFailsWith<IllegalArgumentException> { Canonical.encode(mapOf("x" to 1.5)) }
}
@Test
fun `same content gives same bytes regardless of insertion order`() {
val a = Canonical.bytes(linkedMapOf("x" to 1, "y" to 2))
val b = Canonical.bytes(linkedMapOf("y" to 2, "x" to 1))
assertEquals(a.toList(), b.toList())
}
}
@@ -0,0 +1,50 @@
package net.helcel.owu.crypto
import java.io.File
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
/** An in-memory P-256 identity: what the Keystore provides on a device, minus the vault. */
class JvmSigner(private val pair: KeyPair = generate()) : Signer {
override val publicKey: String = Keys.encode(pair.public)
override fun sign(data: ByteArray): String = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(data)
Keys.encodeSignature(sign())
}
/** Writes the pair to [file] so [load] gives the same identity next time. */
fun save(file: File) {
file.writeText(
Base64.getEncoder().encodeToString(pair.private.encoded) + "\n" +
Base64.getEncoder().encodeToString(pair.public.encoded) + "\n"
)
}
companion object {
private fun generate(): KeyPair = KeyPairGenerator.getInstance("EC")
.apply { initialize(ECGenParameterSpec("secp256r1")) }
.generateKeyPair()
/** The identity saved in [file], or a new one saved there. */
fun load(file: File): JvmSigner {
if (!file.exists()) return JvmSigner().also { it.save(file) }
val (priv, pub) = file.readLines().filter { it.isNotBlank() }
val kf = KeyFactory.getInstance("EC")
return JvmSigner(
KeyPair(
kf.generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(pub))),
kf.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(priv))),
)
)
}
}
}
@@ -0,0 +1,95 @@
package net.helcel.owu.crypto
import java.math.BigInteger
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KeysTest {
@Test
fun `sign and verify round trip`() {
val s = JvmSigner()
val data = "hello".toByteArray()
val sig = s.sign(data)
assertTrue(Keys.verify(s.publicKey, data, sig))
assertFalse(Keys.verify(s.publicKey, "hellp".toByteArray(), sig))
assertFalse(Keys.verify(JvmSigner().publicKey, data, sig))
}
@Test
fun `garbage never verifies or throws`() {
val s = JvmSigner()
assertFalse(Keys.verify(s.publicKey, "x".toByteArray(), "not base64!!"))
assertFalse(Keys.verify(s.publicKey, "x".toByteArray(), ""))
assertFalse(Keys.verify("not a key", "x".toByteArray(), s.sign("x".toByteArray())))
assertNull(Keys.decode("AAAA"))
assertNotNull(Keys.decode(s.publicKey))
}
@Test
fun `the other valid form of the same signature is refused`() {
val signer = JvmSigner()
val data = "a block".toByteArray()
val signature = signer.sign(data)
assertTrue(Keys.verify(signer.publicKey, data, signature))
// ECDSA verifies (r, s) and (r, n - s) alike: anyone who has seen the
// signature can make the second one. It must not be accepted, or a
// OwU could be given two head hashes by a stranger.
val der = Base64.getDecoder().decode(signature)
val flipped = highForm(der)
assertFalse(der.contentEquals(flipped), "the high form differs")
assertTrue(
verifiesRaw(signer.publicKey, data, flipped),
"ECDSA itself still accepts it, which is the point",
)
assertFalse(Keys.verify(signer.publicKey, data, Base64.getEncoder().encodeToString(flipped)))
assertTrue(Keys.canonical(flipped).contentEquals(der), "and it maps back to the one we keep")
}
@Test
fun `padded or malformed DER is refused`() {
val signer = JvmSigner()
val data = "x".toByteArray()
val der = Base64.getDecoder().decode(signer.sign(data))
// A leading zero on r that minimal DER would not write.
val padded = der.copyOfRange(0, 2) + byteArrayOf(0x02, (der[3] + 1).toByte(), 0) +
der.copyOfRange(4, der.size)
padded[1] = (padded.size - 2).toByte()
assertFalse(Keys.verify(signer.publicKey, data, Base64.getEncoder().encodeToString(padded)))
assertFalse(Keys.verify(signer.publicKey, data, Base64.getEncoder().encodeToString(byteArrayOf(0x30, 0))))
}
/** The same signature with s replaced by n - s, re-encoded. */
private fun highForm(der: ByteArray): ByteArray {
val order = BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16)
val rLen = der[3].toInt()
val r = BigInteger(1, der.copyOfRange(4, 4 + rLen))
val sLen = der[5 + rLen].toInt()
val s = BigInteger(1, der.copyOfRange(6 + rLen, 6 + rLen + sLen))
val other = order.subtract(s)
fun int(v: BigInteger) = byteArrayOf(0x02, v.toByteArray().size.toByte()) + v.toByteArray()
val body = int(r) + int(other)
return byteArrayOf(0x30, body.size.toByte()) + body
}
private fun verifiesRaw(publicKey: String, data: ByteArray, der: ByteArray): Boolean =
java.security.Signature.getInstance(Keys.ALGORITHM).run {
initVerify(Keys.decode(publicKey))
update(data)
verify(der)
}
@Test
fun `fingerprint is short and stable`() {
val k = JvmSigner().publicKey
assertEquals(Keys.fingerprint(k), Keys.fingerprint(k))
assertEquals(19, Keys.fingerprint(k).length)
}
}
@@ -0,0 +1,393 @@
package net.helcel.owu.ledger
import net.helcel.owu.crypto.JvmSigner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
class LedgerTest {
private val alice = JvmSigner()
private val bob = JvmSigner()
private val carol = JvmSigner()
private val hug = Metadata(title = "1 Heavy Hug", templateId = "tmpl_hug_01")
private fun valid(iou: Iou): IouState {
val v = Verifier.verify(iou)
assertIs<Verdict.Valid>(v, "expected valid, got $v")
return v.state
}
private fun invalid(iou: Iou, reasonContains: String): Verdict.Invalid {
val v = Verifier.verify(iou)
assertIs<Verdict.Invalid>(v, "expected invalid ($reasonContains), got $v")
assertTrue(v.reason.contains(reasonContains), "reason '${v.reason}' should mention '$reasonContains'")
return v
}
private fun replaceHead(iou: Iou, block: Block) = iou.copy(chain = iou.chain.dropLast(1) + block)
// --- issue -------------------------------------------------------------
@Test
fun `issue produces an active OwU held by the creditor`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
val s = valid(iou)
assertEquals(alice.publicKey, s.debtor)
assertEquals(bob.publicKey, s.holder)
assertEquals(Status.ACTIVE, s.status)
assertEquals(1, s.length)
}
@Test
fun `an OwU issued to yourself is a blank promise you can trade away but not transfer to yourself`() {
val blank = Ledger.issue(alice, creditor = alice.publicKey, metadata = hug)
val s = valid(blank)
assertEquals(alice.publicKey, s.debtor)
assertEquals(alice.publicKey, s.holder)
assertFailsWith<LedgerException> { Ledger.transfer(blank, alice, alice.publicKey) }
val given = Ledger.transfer(blank, alice, bob.publicKey)
assertEquals(bob.publicKey, valid(given).holder)
// and it swaps like any other OwU
val y = Ledger.issue(carol, creditor = bob.publicKey, metadata = Metadata("Y"))
val a = Ledger.acceptExchange(Ledger.proposeExchange(listOf(blank), listOf(y), alice), listOf(y), bob)
assertEquals(bob.publicKey, valid(Ledger.applyExchange(blank, a)).holder)
assertEquals(alice.publicKey, valid(Ledger.applyExchange(y, a)).holder)
}
@Test
fun `edited metadata breaks the genesis hash`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
invalid(iou.copy(metadata = hug.copy(title = "1 Car")), "metadata")
}
@Test
fun `genesis cannot be replayed under another id`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
invalid(iou.copy(id = "another-id"), "signature")
}
@Test
fun `genesis signed by someone other than the debtor is rejected`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
val forged = (iou.chain[0] as Block.Issue).let { g ->
g.copy(debtor = carol.publicKey).let { it.copy(signature = alice.sign(it.signedBytes(iou.id))) }
}
invalid(iou.copy(chain = listOf(forged)), "signature")
}
// --- transfer ----------------------------------------------------------
@Test
fun `holder can transfer, then the new holder can transfer again`() {
var iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
iou = Ledger.transfer(iou, bob, carol.publicKey)
assertEquals(carol.publicKey, valid(iou).holder)
iou = Ledger.transfer(iou, carol, alice.publicKey)
assertEquals(alice.publicKey, valid(iou).holder)
assertEquals(3, valid(iou).length)
}
@Test
fun `non-holder cannot transfer`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
assertFailsWith<LedgerException> { Ledger.transfer(iou, carol, alice.publicKey) }
assertFailsWith<LedgerException> { Ledger.transfer(iou, alice, carol.publicKey) }
}
@Test
fun `forged transfer signed by the wrong key is rejected`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
val state = valid(iou)
val unsigned =
Block.Transfer(1, Ledger.now(), state.headHash, transferor = bob.publicKey, transferee = carol.publicKey)
val forged = unsigned.copy(signature = carol.sign(unsigned.signedBytes(iou.id)))
invalid(iou.copy(chain = iou.chain + forged), "signature")
}
@Test
fun `transfer claiming a non-holder as transferor is rejected`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
val state = valid(iou)
val unsigned =
Block.Transfer(1, Ledger.now(), state.headHash, transferor = carol.publicKey, transferee = alice.publicKey)
val block = unsigned.copy(signature = carol.sign(unsigned.signedBytes(iou.id)))
invalid(iou.copy(chain = iou.chain + block), "not the holder")
}
@Test
fun `tampering with a middle block breaks the chain`() {
var iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
iou = Ledger.transfer(iou, bob, carol.publicKey)
iou = Ledger.transfer(iou, carol, alice.publicKey)
val t1 = iou.chain[1] as Block.Transfer
val tampered = iou.copy(chain = listOf(iou.chain[0], t1.copy(timestamp = t1.timestamp + 1), iou.chain[2]))
assertIs<Verdict.Invalid>(Verifier.verify(tampered))
}
@Test
fun `dropping a block or reordering is rejected`() {
var iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
iou = Ledger.transfer(iou, bob, carol.publicKey)
iou = Ledger.transfer(iou, carol, alice.publicKey)
invalid(iou.copy(chain = listOf(iou.chain[0], iou.chain[2])), "sequence")
invalid(iou.copy(chain = listOf(iou.chain[0], iou.chain[2], iou.chain[1])), "sequence")
}
@Test
fun `a shorter honest prefix is still valid`() {
var iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
iou = Ledger.transfer(iou, bob, carol.publicKey)
assertEquals(bob.publicKey, valid(iou.copy(chain = iou.chain.take(1))).holder)
}
// --- redeem ------------------------------------------------------------
@Test
fun `a promise handed home is closed by the one who made it, and nothing follows`() {
var iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
// While somebody else holds it, nobody can close it - not even alice.
assertFailsWith<LedgerException> { Ledger.redeem(iou, alice) }
assertFailsWith<LedgerException> { Ledger.redeem(iou, bob) }
iou = Ledger.transfer(iou, bob, alice.publicKey)
assertEquals(Status.ACTIVE, valid(iou).status, "home, but still a promise")
assertFailsWith<LedgerException> { Ledger.redeem(iou, bob) }
assertFailsWith<LedgerException> { Ledger.redeem(iou, carol) }
iou = Ledger.redeem(iou, alice)
assertEquals(Status.REDEEMED, valid(iou).status)
assertFailsWith<LedgerException> { Ledger.transfer(iou, alice, carol.publicKey) }
assertFailsWith<LedgerException> { Ledger.redeem(iou, alice) }
assertEquals(iou, IouJson.decode(IouJson.encode(iou)))
}
@Test
fun `an OwU closed while somebody else held it is rejected`() {
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
val state = valid(iou)
val unsigned = Block.Redeemed(1, Ledger.now(), state.headHash, debtor = alice.publicKey)
val block = unsigned.copy(signature = alice.sign(unsigned.signedBytes(iou.id)))
invalid(iou.copy(chain = iou.chain + block), "while somebody else held it")
}
@Test
fun `block appended after redemption is rejected`() {
var iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = hug)
iou = Ledger.transfer(iou, bob, alice.publicKey)
iou = Ledger.redeem(iou, alice)
val state = valid(iou)
val unsigned =
Block.Transfer(3, Ledger.now(), state.headHash, transferor = bob.publicKey, transferee = carol.publicKey)
val block = unsigned.copy(signature = bob.sign(unsigned.signedBytes(iou.id)))
invalid(iou.copy(chain = iou.chain + block), "after redemption")
}
// --- exchange ----------------------------------------------------------
private fun twoIous(): Pair<Iou, Iou> {
val x = Ledger.issue(
alice,
creditor = bob.publicKey,
metadata = hug
) // bob holds X (alice owes)
val y = Ledger.issue(
carol,
creditor = alice.publicKey,
metadata = Metadata("1 Coffee")
) // alice holds Y (carol owes)
return x to y
}
@Test
fun `full exchange swaps holders on both chains`() {
val (x, y) = twoIous()
val proposal = Ledger.proposeExchange(mine = listOf(x), theirs = listOf(y), signer = bob)
assertTrue(!proposal.complete)
val agreement = Ledger.acceptExchange(proposal, mine = listOf(y), signer = alice)
assertTrue(agreement.complete)
val x2 = Ledger.applyExchange(x, agreement)
val y2 = Ledger.applyExchange(y, agreement)
assertEquals(alice.publicKey, valid(x2).holder)
assertEquals(bob.publicKey, valid(y2).holder)
assertEquals(alice.publicKey, valid(x2).debtor)
assertEquals(carol.publicKey, valid(y2).debtor)
// Either party can apply both once both have signed.
assertEquals(valid(x2), valid(Ledger.applyExchange(x, agreement)))
}
@Test
fun `an unaccepted proposal cannot be applied`() {
val (x, y) = twoIous()
val proposal = Ledger.proposeExchange(listOf(x), listOf(y), bob)
assertFailsWith<LedgerException> { Ledger.applyExchange(x, proposal) }
assertFailsWith<LedgerException> { Ledger.applyExchange(y, proposal) }
}
@Test
fun `only the counterparty holder can accept`() {
val (x, y) = twoIous()
val proposal = Ledger.proposeExchange(listOf(x), listOf(y), bob)
assertFailsWith<LedgerException> { Ledger.acceptExchange(proposal, listOf(y), carol) }
assertFailsWith<LedgerException> { Ledger.acceptExchange(proposal, listOf(y), bob) }
assertFailsWith<LedgerException> { Ledger.acceptExchange(proposal, listOf(x), alice) }
}
@Test
fun `agreement dies if either OwU moves first`() {
val (x, y) = twoIous()
val agreement = Ledger.acceptExchange(Ledger.proposeExchange(listOf(x), listOf(y), bob), listOf(y), alice)
val yMoved = Ledger.transfer(Ledger.transfer(y, alice, carol.publicKey), carol, alice.publicKey)
assertEquals(alice.publicKey, valid(yMoved).holder) // same holder, different head
assertFailsWith<LedgerException> { Ledger.applyExchange(yMoved, agreement) }
// and a hand-built block on the moved chain is rejected by the verifier
val state = valid(yMoved)
val forged = Block.Transfer(
sequence = state.length,
timestamp = agreement.timestamp,
parentHash = state.headHash,
transferor = agreement.side(y.id)!!.holder,
transferee = agreement.other(y.id)!!.holder,
agreement = agreement,
signature = agreement.side(y.id)!!.signature!!,
)
invalid(yMoved.copy(chain = yMoved.chain + forged), "different head")
}
@Test
fun `exchange block with a forged counterparty signature is rejected`() {
val (x, y) = twoIous()
val proposal = Ledger.proposeExchange(listOf(x), listOf(y), bob)
// carol pretends to be alice accepting
val fake = proposal.copy(right = proposal.right.copy(signature = carol.sign(proposal.signingBytes())))
assertTrue(fake.complete)
val state = valid(x)
val block = Block.Transfer(
sequence = state.length,
timestamp = fake.timestamp,
parentHash = state.headHash,
transferor = fake.side(x.id)!!.holder,
transferee = fake.other(x.id)!!.holder,
agreement = fake,
signature = fake.side(x.id)!!.signature!!,
)
invalid(x.copy(chain = x.chain + block), "counterparty signature")
}
/**
* A TRANSFER says who it moves the OwU between, and a swap's agreement
* says the same. They have to agree, or the block moves it somewhere the
* signed deal never mentioned.
*/
@Test
fun `a swap cannot send the OwU anywhere but where its agreement says`() {
val (x, y) = twoIous()
val agreement = Ledger.acceptExchange(Ledger.proposeExchange(listOf(x), listOf(y), bob), listOf(y), alice)
val state = valid(x)
val block = Block.Transfer(
sequence = state.length,
timestamp = agreement.timestamp,
parentHash = state.headHash,
transferor = agreement.side(x.id)!!.holder,
transferee = carol.publicKey,
agreement = agreement,
signature = agreement.side(x.id)!!.signature!!,
)
invalid(x.copy(chain = x.chain + block), "somebody else")
}
@Test
fun `exchange block must carry this side's own agreement signature`() {
val (x, y) = twoIous()
val agreement = Ledger.acceptExchange(Ledger.proposeExchange(listOf(x), listOf(y), bob), listOf(y), alice)
val state = valid(x)
val block = Block.Transfer(
sequence = state.length,
timestamp = agreement.timestamp,
parentHash = state.headHash,
transferor = agreement.side(x.id)!!.holder,
transferee = agreement.other(x.id)!!.holder,
agreement = agreement,
// The other side's signature, not this side's.
signature = agreement.other(x.id)!!.signature!!,
)
invalid(x.copy(chain = x.chain + block), "signature")
}
@Test
fun `cannot propose a swap for an OwU you do not hold, or against one you already hold`() {
val (x, y) = twoIous()
assertFailsWith<LedgerException> { Ledger.proposeExchange(listOf(x), listOf(y), alice) }
val z = Ledger.issue(carol, creditor = bob.publicKey, metadata = Metadata("1 Beer"))
assertFailsWith<LedgerException> { Ledger.proposeExchange(listOf(x), listOf(z), bob) }
}
// --- serialization -----------------------------------------------------
@Test
fun `json round trip preserves validity and equality`() {
val (x, y) = twoIous()
val agreement = Ledger.acceptExchange(Ledger.proposeExchange(listOf(x), listOf(y), bob), listOf(y), alice)
var iou = Ledger.applyExchange(x, agreement)
iou = Ledger.transfer(iou, alice, carol.publicKey)
iou = Ledger.transfer(iou, carol, alice.publicKey)
iou = Ledger.redeem(iou, alice)
val text = IouJson.encode(iou)
val back = IouJson.decode(text)
assertEquals(iou, back)
assertEquals(valid(iou), valid(back))
assertTrue(text.contains("\"action\":\"ISSUE\""))
assertTrue(text.contains("\"action\":\"TRANSFER\""))
// A swap is a TRANSFER carrying the agreement; a plain one carries none.
assertTrue(text.contains("\"agreement\""))
assertTrue(text.contains("\"parent_hash\""))
val a = IouJson.decodeAgreement(IouJson.encode(agreement))
assertEquals(agreement, a)
}
@Test
fun `redemption window is signed, checked for order, and gated`() {
val meta = hug.copy(notBefore = 1_800_000_000L, notAfter = 1_800_100_000L)
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = meta)
valid(iou)
invalid(iou.copy(metadata = meta.copy(notAfter = 1_900_000_000L)), "metadata")
assertEquals(iou, IouJson.decode(IouJson.encode(iou)))
assertFailsWith<LedgerException> {
Ledger.issue(
alice,
creditor = bob.publicKey,
metadata = hug.copy(notBefore = 10, notAfter = 5)
)
}
assertEquals(TimeGate.NotYet(1_800_000_000L), meta.timeGate(now = 1_799_999_999L))
assertEquals(TimeGate.Open, meta.timeGate(now = 1_800_000_000L))
assertEquals(TimeGate.Open, meta.timeGate(now = 1_800_100_000L))
assertEquals(TimeGate.Expired(1_800_100_000L), meta.timeGate(now = 1_800_100_001L))
assertEquals(TimeGate.Open, hug.timeGate(now = 0))
assertEquals(TimeGate.Open, hug.copy(notAfter = 100).timeGate(now = 50))
assertEquals(TimeGate.Expired(100), hug.copy(notAfter = 100).timeGate(now = 150))
// The window is not a protocol rule: handing it back outside the
// window still verifies, and so does closing it.
val home = Ledger.transfer(iou, bob, alice.publicKey, timestamp = 1_900_000_000L)
valid(Ledger.redeem(home, alice, timestamp = 1_900_000_000L))
}
@Test
fun `geoloc is part of the signed metadata`() {
val meta = hug.copy(geoloc = GeoLoc.of(46.5197, 6.6323, radiusM = 200, label = "Lausanne"))
val iou = Ledger.issue(alice, creditor = bob.publicKey, metadata = meta)
valid(iou)
val moved = iou.copy(metadata = meta.copy(geoloc = meta.geoloc!!.copy(radiusM = 5000)))
invalid(moved, "metadata")
assertEquals(iou, IouJson.decode(IouJson.encode(iou)))
assertEquals(46.5197, iou.metadata.geoloc!!.latitude, 1e-6)
}
}
@@ -0,0 +1,670 @@
package net.helcel.owu.peer
import net.helcel.owu.crypto.JvmSigner
import net.helcel.owu.crypto.Signer
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Verifier
import java.util.UUID
import net.helcel.owu.store.Contact
import net.helcel.owu.store.IouStore
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PeerEngineTest {
/** A device: its own store on disk and its engine for one session. */
private class Device(val signer: Signer = JvmSigner(), val name: String) {
val store = IouStore(Files.createTempDirectory("iou").toFile())
lateinit var engine: PeerEngine
val events = mutableListOf<PeerEngine.Event>()
val outbox = ArrayDeque<PeerMessage>()
fun session() {
events.clear(); outbox.clear(); engine = PeerEngine(store, signer, name); take(engine.start())
}
fun take(step: PeerEngine.Step) {
outbox += step.send; events += step.events
}
fun act(block: PeerEngine.() -> PeerEngine.Step) = take(engine.block())
fun iou(id: String) = store.ious.value.getValue(id)
fun state(id: String) = Verifier.verify(iou(id)).stateOrNull!!
inline fun <reified E : PeerEngine.Event> only(): E = events.filterIsInstance<E>().single()
fun clean() = assertTrue(events.none { it is PeerEngine.Event.Failed }, "$name: $events")
}
/** Ferries messages both ways, byte-encoded like the real link, until nothing moves. */
private fun pump(a: Device, b: Device) {
while (a.outbox.isNotEmpty() || b.outbox.isNotEmpty()) {
a.outbox.removeFirstOrNull()?.let { b.take(b.engine.onMessage(PeerMessage.decode(PeerMessage.encode(it)))) }
b.outbox.removeFirstOrNull()?.let { a.take(a.engine.onMessage(PeerMessage.decode(PeerMessage.encode(it)))) }
}
}
private fun connect(a: Device, b: Device) {
a.session(); b.session(); pump(a, b)
}
private val alice = Device(name = "Alice")
private val bob = Device(name = "Bob")
private val carol = Device(name = "Carol")
@Test
fun `peers authenticate and nothing else happens`() {
connect(alice, bob)
assertEquals("Bob", alice.engine.peer?.name)
assertEquals(bob.signer.publicKey, alice.engine.peer?.key)
assertEquals("Alice", bob.engine.peer?.name)
assertEquals(1, alice.events.size); assertEquals(1, bob.events.size)
alice.clean(); bob.clean()
}
@Test
fun `a lost first HELLO is recovered by asking again, and repeats are harmless`() {
alice.session(); bob.session()
// Alice's HELLO never reaches Bob (his radio was not up yet).
alice.outbox.removeFirst()
pump(alice, bob)
// Bob's HELLO reached Alice, who answered; Bob got an AUTH he cannot place,
// and never answered Alice's HELLO because he never saw it. Neither is verified.
assertNull(alice.engine.peer)
assertNull(bob.engine.peer)
assertTrue(bob.events.none { it is PeerEngine.Event.Failed }, "an early AUTH is kept, not an error")
// Alice says hello again (same nonce); now both sides complete.
alice.act { reintroduce() }
pump(alice, bob)
assertEquals("Bob", alice.engine.peer?.name)
assertEquals("Alice", bob.engine.peer?.name)
assertTrue(alice.events.none { it is PeerEngine.Event.Restarted })
assertTrue(bob.events.none { it is PeerEngine.Event.Failed }, bob.events.toString())
// A third hello from either side, once verified, only draws a fresh AUTH - no restart.
bob.act { reintroduce() }
pump(alice, bob)
assertTrue(alice.events.none { it is PeerEngine.Event.Restarted })
assertTrue(bob.events.none { it is PeerEngine.Event.Restarted })
assertEquals(1, alice.events.count { it is PeerEngine.Event.PeerIdentified })
}
@Test
fun `a peer that restarts with a new nonce is verified afresh`() {
connect(alice, bob)
bob.session() // Bob's app restarted: new engine, new nonce
pump(alice, bob)
assertTrue(alice.events.any { it is PeerEngine.Event.Restarted })
assertEquals("Bob", alice.engine.peer?.name)
assertEquals("Alice", bob.engine.peer?.name)
alice.clean(); bob.clean()
}
@Test
fun `an impostor claiming another key is rejected`() {
val mallory = Device(name = "Bob")
alice.session()
val aliceHello = alice.outbox.removeFirst() as PeerMessage.Hello
alice.take(
alice.engine.onMessage(
PeerMessage.Hello(
IdentityCard(name = "Bob", key = bob.signer.publicKey),
nonce = "00"
)
)
)
assertIs<PeerMessage.Auth>(alice.outbox.removeFirst())
val forgedAuth =
PeerMessage.Auth(mallory.signer.sign(PeerEngine.authBytes(aliceHello.nonce, bob.signer.publicKey)))
alice.take(alice.engine.onMessage(forgedAuth))
assertNull(alice.engine.peer)
assertIs<PeerEngine.Event.Failed>(alice.events.single())
// Anything they send while unproven is answered with who we are - the
// same card we offer anybody who connects - and with nothing else. No
// table is taken, no OwU is kept, nothing of ours goes out.
val leak = alice.engine.onMessage(
PeerMessage.Table(
bob.signer.publicKey,
listOf(Ledger.issue(bob.signer, metadata = Metadata("x")))
)
)
assertTrue(leak.events.isEmpty(), "${leak.events}")
assertIs<PeerMessage.Hello>(leak.send.single())
assertNull(alice.engine.peer)
assertTrue(alice.store.ious.value.isEmpty(), "nothing of theirs was kept")
}
@Test
fun `an acceptance stands on its own when the table before it is lost`() {
val promise = Ledger.issue(alice.signer, metadata = Metadata("1 Coffee"))
alice.store.put(promise)
connect(alice, bob)
alice.act { put(listOf(promise)) }
// The TABLE never arrives: drop it on the floor as the air would.
val table = alice.outbox.removeFirst()
assertIs<PeerMessage.Table>(table)
alice.act { accept() }
pump(alice, bob)
assertEquals(1, bob.engine.table().theirs.size, "bob has their side from the acceptance alone")
assertTrue(bob.engine.table().theyAccepted, "and knows they have said yes")
bob.act { accept() }; pump(alice, bob)
assertEquals(bob.signer.publicKey, bob.state(promise.id).holder, "it still changes hands")
assertEquals(alice.iou(promise.id), bob.iou(promise.id))
alice.clean(); bob.clean()
}
// --- bundles -----------------------------------------------------------
@Test
fun `a bundle of many minted promises is given as one`() {
connect(alice, bob)
val beer = Metadata("1 Beer")
val hug = Metadata("1 Heavy Hug")
val trip = Metadata("1 Surprise trip")
alice.act { put(mint = List(5) { beer } + List(2) { hug } + listOf(trip)) }
pump(alice, bob)
assertEquals(8, bob.engine.table().theirs.size, "bob sees the whole bundle")
assertEquals(5, bob.engine.table().theirs.count { it.metadata == beer })
assertTrue(bob.store.ious.value.isEmpty(), "nothing is kept until both accept")
alice.act { accept() }; pump(alice, bob)
bob.act { accept() }; pump(alice, bob)
assertEquals(8, bob.store.ious.value.size, "all eight arrive")
assertTrue(bob.store.ious.value.values.all { bob.state(it.id).holder == bob.signer.publicKey })
assertTrue(alice.store.ious.value.values.all { alice.state(it.id).debtor == alice.signer.publicKey })
assertEquals(8, (alice.only<PeerEngine.Event.Done>()).gave.size)
alice.clean(); bob.clean()
}
@Test
fun `bundles swap atomically, every OwU or none`() {
val x1 = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("X1"))
val x2 = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("X2"))
val y1 = Ledger.issue(carol.signer, creditor = bob.signer.publicKey, metadata = Metadata("Y1"))
val y2 = Ledger.issue(carol.signer, creditor = bob.signer.publicKey, metadata = Metadata("Y2"))
val y3 = Ledger.issue(carol.signer, creditor = bob.signer.publicKey, metadata = Metadata("Y3"))
listOf(x1, x2).forEach { alice.store.put(it) }
listOf(y1, y2, y3).forEach { bob.store.put(it) }
connect(alice, bob)
alice.act { put(listOf(x1, x2)) }; pump(alice, bob)
bob.act { put(listOf(y1, y2, y3)) }; pump(alice, bob)
alice.act { accept() }; pump(alice, bob)
bob.act { accept() }; pump(alice, bob)
// Two for three: everything crossed, and both sides agree on each chain.
listOf(x1, x2).forEach { assertEquals(bob.signer.publicKey, alice.state(it.id).holder, it.metadata.title) }
listOf(y1, y2, y3).forEach {
assertEquals(
alice.signer.publicKey,
alice.state(it.id).holder,
it.metadata.title
)
}
listOf(x1, x2, y1, y2, y3).forEach { assertEquals(alice.iou(it.id), bob.iou(it.id), it.metadata.title) }
// One agreement covers the lot, so every block carries the same id.
val ids = listOf(x1, x2, y1, y2, y3)
.map { (alice.iou(it.id).head as net.helcel.owu.ledger.Block.Transfer).agreement!!.id }
.toSet()
assertEquals(1, ids.size, "one agreement for the whole bundle")
alice.clean(); bob.clean()
}
@Test
fun `a bundle handed home to its author closes all of it`() {
connect(alice, bob)
alice.act { put(mint = List(3) { Metadata("1 Beer") }) }; pump(alice, bob)
alice.act { accept() }; pump(alice, bob)
bob.act { accept() }; pump(alice, bob)
val beers = bob.store.ious.value.keys.toList()
assertEquals(3, beers.size)
// Bob hands all three back; Alice owes them, so all three close.
bob.act { put(beers.map { bob.iou(it) }) }; pump(alice, bob)
bob.act { accept() }; pump(alice, bob)
alice.act { accept() }; pump(alice, bob)
beers.forEach {
assertEquals(Status.REDEEMED, alice.state(it).status, "alice closed it")
assertEquals(Status.REDEEMED, bob.state(it).status, "bob has the receipt")
}
alice.clean(); bob.clean()
}
// --- the table ---------------------------------------------------------
@Test
fun `a promise put down and accepted by both becomes theirs`() {
val promise = Ledger.issue(alice.signer, metadata = Metadata("1 Coffee"))
alice.store.put(promise)
connect(alice, bob)
alice.act { put(listOf(promise)) }
pump(alice, bob)
assertEquals(promise.id, bob.engine.table().theirs.singleOrNull()?.id, "bob sees what alice put down")
assertNull(bob.store.ious.value[promise.id], "nothing is kept until both accept")
bob.act { accept() }
pump(alice, bob)
assertFalse(alice.engine.table().accepted, "alice has not said yes yet")
assertTrue(alice.engine.table().theyAccepted)
assertNull(bob.store.ious.value[promise.id], "one yes is not enough")
alice.act { accept() }
pump(alice, bob)
for (d in listOf(alice, bob)) {
assertEquals(bob.signer.publicKey, d.state(promise.id).holder, "${d.name}: holder")
assertEquals(alice.signer.publicKey, d.state(promise.id).debtor, "${d.name}: debtor")
assertTrue(d.engine.table().empty, "${d.name}: table cleared")
d.clean()
}
assertEquals(alice.iou(promise.id), bob.iou(promise.id))
assertEquals(promise.id, alice.only<PeerEngine.Event.Done>().gave.single().id)
assertEquals(promise.id, bob.only<PeerEngine.Event.Done>().got.single().id)
}
@Test
fun `either side can accept first and an OwU already held travels the same way`() {
val x = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("carol owes alice"))
alice.store.put(x)
connect(alice, bob)
alice.act { put(listOf(x)) }; pump(alice, bob)
alice.act { accept() }; pump(alice, bob)
assertEquals(alice.signer.publicKey, alice.state(x.id).holder, "nothing moves on one yes")
bob.act { accept() }; pump(alice, bob)
assertEquals(bob.signer.publicKey, bob.state(x.id).holder)
assertEquals(alice.iou(x.id), bob.iou(x.id))
alice.clean(); bob.clean()
}
@Test
fun `two ious on the table swap atomically, whoever accepts first`() {
val x = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("X"))
val y = Ledger.issue(carol.signer, creditor = bob.signer.publicKey, metadata = Metadata("Y"))
alice.store.put(x); bob.store.put(y)
connect(alice, bob)
alice.act { put(listOf(x)) }; pump(alice, bob)
bob.act { put(listOf(y)) }; pump(alice, bob)
assertEquals(y.id, alice.engine.table().theirs.singleOrNull()?.id)
assertEquals(x.id, bob.engine.table().theirs.singleOrNull()?.id)
bob.act { accept() }; pump(alice, bob)
assertEquals(alice.signer.publicKey, alice.state(x.id).holder, "one yes moves nothing")
alice.act { accept() }; pump(alice, bob)
for (d in listOf(alice, bob)) {
assertEquals(bob.signer.publicKey, d.state(x.id).holder, "${d.name}: x")
assertEquals(alice.signer.publicKey, d.state(y.id).holder, "${d.name}: y")
assertEquals(2, d.iou(x.id).chain.size)
val done = d.only<PeerEngine.Event.Done>()
assertNotNull(done.gave.singleOrNull()); assertNotNull(done.got.singleOrNull())
d.clean()
}
assertEquals(alice.iou(x.id), bob.iou(x.id))
assertEquals(alice.iou(y.id), bob.iou(y.id))
}
@Test
fun `changing the table withdraws both yeses`() {
val x = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("X"))
val other = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("other"))
alice.store.put(x); alice.store.put(other)
connect(alice, bob)
alice.act { put(listOf(x)) }; pump(alice, bob)
bob.act { accept() }; pump(alice, bob)
assertTrue(alice.engine.table().theyAccepted)
alice.act { put(listOf(other)) }; pump(alice, bob)
assertFalse(alice.engine.table().theyAccepted, "their yes was to the old table")
assertFalse(bob.engine.table().accepted, "and bob knows it")
assertEquals(other.id, bob.engine.table().theirs.singleOrNull()?.id)
// The yes they gave earlier cannot be replayed against the new table.
alice.take(alice.engine.onMessage(PeerMessage.Accept(bob.signer.publicKey, "stale-deal")))
assertIs<PeerEngine.Event.Failed>(alice.events.last())
assertEquals(alice.signer.publicKey, alice.state(other.id).holder)
}
@Test
fun `an empty table cannot be accepted, and an OwU the other side does not hold is refused`() {
connect(alice, bob)
assertTrue(runCatching { alice.engine.accept() }.isFailure, "nothing on the table")
val bobs = Ledger.issue(carol.signer, creditor = bob.signer.publicKey, metadata = Metadata("bob's"))
bob.take(bob.engine.onMessage(PeerMessage.Table(alice.signer.publicKey, listOf(bobs))))
assertIs<PeerEngine.Event.Failed>(bob.events.last())
assertTrue(bob.engine.table().empty)
}
@Test
fun `a swap signed by someone else is refused`() {
val x = Ledger.issue(carol.signer, creditor = alice.signer.publicKey, metadata = Metadata("X"))
val y = Ledger.issue(carol.signer, creditor = bob.signer.publicKey, metadata = Metadata("Y"))
alice.store.put(x); bob.store.put(y)
connect(alice, bob)
alice.act { put(listOf(x)) }; pump(alice, bob)
bob.act { put(listOf(y)) }; pump(alice, bob)
// Carol signs a proposal that claims to be Bob's.
val forged = Ledger.proposeExchange(listOf(y), listOf(x), object : Signer {
override val publicKey = bob.signer.publicKey
override fun sign(data: ByteArray) = carol.signer.sign(data)
})
val deal = (bob.engine.accept().send.single() as PeerMessage.Accept).deal
alice.take(alice.engine.onMessage(PeerMessage.Accept(bob.signer.publicKey, deal, listOf(y), forged)))
assertIs<PeerEngine.Event.Failed>(alice.events.last())
assertEquals(alice.signer.publicKey, alice.state(x.id).holder)
}
@Test
fun `a name is remembered once the key behind it is proven, and never overwrites yours`() {
connect(alice, bob)
assertEquals("Bob", alice.store.metName(bob.signer.publicKey), "learned from the handshake")
assertEquals("Alice", bob.store.metName(alice.signer.publicKey))
assertNull(alice.store.nameFor(bob.signer.publicKey), "but not made a contact by itself")
// Having named him yourself, that is what counts; his own claim is
// still kept, and is not allowed to overwrite the name you chose.
alice.store.putContact(Contact(bob.signer.publicKey, "Bobby"))
val liar = Device(signer = bob.signer, name = "Someone Else")
connect(alice, liar)
assertEquals("Bobby", alice.store.nameFor(bob.signer.publicKey))
assertEquals("Someone Else", alice.store.metName(bob.signer.publicKey))
}
@Test
fun `a promise kept ready mints a fresh OwU every time it is handed over`() {
val beer = Metadata("1 Beer")
connect(alice, bob)
alice.act { putNew(beer) }
pump(alice, bob)
val first = alice.engine.table().mine.singleOrNull()!!.id
bob.act { accept() }; alice.act { accept() }
pump(alice, bob)
assertEquals(bob.signer.publicKey, bob.state(first).holder)
// The same promise again: a different OwU, signed afresh.
alice.act { putNew(beer) }
pump(alice, bob)
val second = alice.engine.table().mine.singleOrNull()!!.id
assertNotEquals(first, second, "each pour is its own OwU")
bob.act { accept() }; alice.act { accept() }
pump(alice, bob)
assertEquals(bob.signer.publicKey, bob.state(second).holder)
assertEquals(2, bob.store.ious.value.count { it.value.metadata == beer }, "bob holds two beers")
alice.clean(); bob.clean()
}
@Test
fun `a minted OwU that never leaves is dropped again`() {
connect(alice, bob)
alice.act { putNew(Metadata("1 Beer")) }
pump(alice, bob)
val minted = alice.engine.table().mine.singleOrNull()!!.id
assertNotNull(alice.store.ious.value[minted])
// Changing my mind takes it off the table and out of the ledger.
alice.act { put() }
pump(alice, bob)
assertNull(alice.store.ious.value[minted], "nothing was promised, so nothing is kept")
assertNull(bob.store.ious.value[minted], "and bob never kept it either")
// Parting company does the same for one still on the table.
alice.act { putNew(Metadata("1 Beer")) }
pump(alice, bob)
val second = alice.engine.table().mine.singleOrNull()!!.id
alice.engine.abandon()
assertNull(alice.store.ious.value[second])
alice.clean(); bob.clean()
}
@Test
fun `an OwU signed over to two people is caught at the table, not just at redemption`() {
// Bob signs the same OwU over to Carol and to Dave, offline.
val dave = Device(name = "Dave")
val OwU = Ledger.issue(alice.signer, creditor = bob.signer.publicKey, metadata = Metadata("1 Coffee"))
val toCarol = Ledger.transfer(OwU, bob.signer, carol.signer.publicKey)
val toDave = Ledger.transfer(OwU, bob.signer, dave.signer.publicKey)
carol.store.put(toCarol); dave.store.put(toDave)
// Carol has seen her copy. Dave now offers his at the table.
connect(carol, dave)
dave.act { put(listOf(toDave)) }
pump(carol, dave)
assertTrue(carol.engine.table().conflict, "carol can see the two histories herself")
assertTrue(runCatching { carol.engine.accept() }.isFailure, "and cannot accept against it")
assertEquals(carol.signer.publicKey, carol.state(OwU.id).holder, "her copy is untouched")
// Offering an OwU at a head that has already moved on is the same
// trick with one copy: Dave gives his OwU away, then offers the
// version from before he did.
val given = Ledger.transfer(toDave, dave.signer, carol.signer.publicKey)
carol.store.put(given)
dave.act { put(listOf(toDave)) }
pump(carol, dave)
assertTrue(carol.engine.table().conflict, "carol knows that OwU has moved on")
// An OwU nobody has a second copy of raises nothing.
val clean = Ledger.issue(dave.signer, metadata = Metadata("1 Clean"))
dave.store.put(clean)
dave.act { put(listOf(clean)) }
pump(carol, dave)
assertFalse(carol.engine.table().conflict)
carol.clean()
}
// --- redeem ------------------------------------------------------------
@Test
fun `redeeming is handing the OwU home, and the debtor closes it`() {
val iou = handedOver(alice, bob.signer.publicKey, Metadata("1 Coffee"))
alice.store.put(iou); bob.store.put(iou)
connect(alice, bob)
// Bob puts alice's own promise on the table; nothing else is needed.
bob.act { put(listOf(iou)) }
pump(alice, bob)
alice.act { accept() }; bob.act { accept() }
pump(alice, bob)
for (d in listOf(alice, bob)) {
assertEquals(Status.REDEEMED, d.state(iou.id).status, "${d.name}")
assertEquals(alice.signer.publicKey, d.state(iou.id).holder, "${d.name}: home")
d.clean()
}
assertEquals(alice.iou(iou.id), bob.iou(iou.id))
alice.only<PeerEngine.Event.Redeemed>()
bob.only<PeerEngine.Event.Redeemed>()
}
@Test
fun `a promise that comes home in a swap closes itself, and they are told`() {
val mine = handedOver(alice, bob.signer.publicKey, Metadata("1 Coffee"), id = "a-mine")
val theirs = handedOver(carol.signer, alice.signer.publicKey, Metadata("1 Tea"), id = "b-theirs")
alice.store.put(mine); alice.store.put(theirs); bob.store.put(mine)
connect(alice, bob)
alice.act { put(listOf(theirs)) }; pump(alice, bob)
bob.act { put(listOf(mine)) }; pump(alice, bob)
alice.act { accept() }; pump(alice, bob)
bob.act { accept() }; pump(alice, bob)
// Her own promise came back in the swap: nobody else could ever be
// asked for it, so it is spent - on both phones.
for (d in listOf(alice, bob)) {
assertEquals(Status.REDEEMED, d.state(mine.id).status, "${d.name}: the coffee")
assertEquals(Status.ACTIVE, d.state(theirs.id).status, "${d.name}: the tea carries on")
}
assertEquals(alice.iou(mine.id), bob.iou(mine.id))
assertEquals(bob.signer.publicKey, alice.state(theirs.id).holder, "the tea went to bob")
alice.only<PeerEngine.Event.Redeemed>(); bob.only<PeerEngine.Event.Redeemed>()
alice.clean(); bob.clean()
}
@Test
fun `an OwU that was already redeemed cannot be put on the table again`() {
val iou = handedOver(alice, bob.signer.publicKey, Metadata("1 Coffee"))
val home = Ledger.transfer(iou, bob.signer, alice.signer.publicKey)
val closed = Ledger.redeem(home, alice.signer)
bob.store.put(closed)
connect(alice, bob)
assertTrue(runCatching { bob.engine.put(listOf(closed)) }.isFailure, "spent")
}
@Test
fun `a double-spent branch is refused by the debtor once one copy has come home`() {
val dave = Device(name = "Dave")
val iou = handedOver(alice, bob.signer.publicKey, Metadata("1 Coffee"))
alice.store.put(iou)
// Bob signs the same OwU over to Carol and to Dave, offline.
val toCarol = Ledger.transfer(iou, bob.signer, carol.signer.publicKey)
val toDave = Ledger.transfer(iou, bob.signer, dave.signer.publicKey)
carol.store.put(toCarol); dave.store.put(toDave)
// Carol gets there first and redeems hers.
connect(alice, carol)
carol.act { put(listOf(toCarol)) }; pump(alice, carol)
carol.act { accept() }; alice.act { accept() }; pump(alice, carol)
assertEquals(Status.REDEEMED, carol.state(iou.id).status)
// Dave shows up with the other branch: alice sees the contradiction
// on the table and cannot accept it.
connect(alice, dave)
dave.act { put(listOf(toDave)) }; pump(alice, dave)
assertTrue(alice.engine.table().conflict, "alice holds a copy that says otherwise")
assertTrue(runCatching { alice.engine.accept() }.isFailure)
assertEquals(Status.REDEEMED, alice.state(iou.id).status, "hers is the one that counted")
alice.clean(); carol.clean()
}
/** A promise written by [debtor] and handed to [holder], as every moved OwU is. */
/**
* A refusal is not silence. Turning down what is on the table clears it
* on both sides, so the one who asked stops waiting and can ask again.
*/
@Test
fun `refusing the table tells the other side and clears it for both`() {
val iou = handedOver(bob, alice.signer.publicKey, Metadata("1 Beer"))
alice.store.put(iou); bob.store.put(iou)
connect(alice, bob)
// Alice asks Bob to redeem, as the Redeem button does.
alice.act { put(listOf(iou)) }
alice.act { accept() }
pump(alice, bob)
assertTrue(bob.engine.table().theirs.isNotEmpty(), "the ask is on his table")
bob.events.clear(); alice.events.clear()
bob.act { decline(PeerMessage.Asked.TABLE) }
pump(alice, bob)
assertEquals(PeerMessage.Asked.TABLE, alice.only<PeerEngine.Event.Declined>().to)
for (d in listOf(alice, bob)) {
assertTrue(d.engine.table().mine.isEmpty() && d.engine.table().theirs.isEmpty(), "${d.name}: table cleared")
d.clean()
}
// Nothing moved: the promise is still hers, still open.
assertEquals(Status.ACTIVE, alice.state(iou.id).status)
assertEquals(alice.signer.publicKey, alice.state(iou.id).holder)
// And she may ask again.
alice.act { put(listOf(iou)) }
alice.act { accept() }
pump(alice, bob)
bob.act { accept() }
pump(alice, bob)
assertEquals(Status.REDEEMED, alice.state(iou.id).status)
}
/** Turning down an invitation says so, and touches nothing. */
@Test
fun `refusing an invitation tells the other side`() {
connect(alice, bob)
alice.act { invite() }
pump(alice, bob)
alice.events.clear()
bob.act { decline(PeerMessage.Asked.INVITE) }
pump(alice, bob)
assertEquals(PeerMessage.Asked.INVITE, alice.only<PeerEngine.Event.Declined>().to)
alice.clean(); bob.clean()
// A refusal signed with somebody else's key is not theirs to send.
val forged = alice.engine.onMessage(PeerMessage.Decline(carol.signer.publicKey, PeerMessage.Asked.TABLE))
assertIs<PeerEngine.Event.Failed>(forged.events.single())
}
/** Opening a table reaches the other person, and only a known one. */
@Test
fun `an invitation to the table arrives as an invitation`() {
connect(alice, bob)
alice.events.clear(); bob.events.clear()
alice.act { invite() }
pump(alice, bob)
assertEquals(alice.signer.publicKey, bob.only<PeerEngine.Event.Invited>().card.key)
assertTrue(alice.events.isEmpty(), "nothing comes back: ${alice.events}")
bob.clean()
// Nothing on the table changed, and nobody has accepted anything.
assertTrue(bob.engine.table().mine.isEmpty() && bob.engine.table().theirs.isEmpty())
// An invitation signed with somebody else's key is not theirs to send.
val forged = bob.engine.onMessage(PeerMessage.Invite(carol.signer.publicKey))
assertIs<PeerEngine.Event.Failed>(forged.events.single())
}
/**
* The deadlock that made a redeem sit on "waiting for them to accept"
* for ever: one radio restarts, so one side has never met the other,
* while that other side goes on believing they were introduced and talks
* as though they had been. Neither ever says hello again.
*/
@Test
fun `a peer that no longer knows us is told who we are, and the ask can be made again`() {
val iou = handedOver(bob, alice.signer.publicKey, Metadata("1 Beer"))
alice.store.put(iou); bob.store.put(iou)
connect(alice, bob)
// Bob's radio restarts: a new engine that has never met Alice, whose
// hello never reaches her. She asks to redeem as though all were well.
bob.session()
bob.outbox.clear()
alice.act { put(listOf(iou)) }
alice.act { accept() }
pump(alice, bob)
// Nobody is stuck: they have introduced themselves afresh.
assertEquals(alice.signer.publicKey, bob.engine.peer?.key, "bob: ${bob.events}")
assertEquals(bob.signer.publicKey, alice.engine.peer?.key, "alice: ${alice.events}")
// Her table went with the restart, which is why the ask is kept
// until it is seen on their table rather than until it is sent.
assertTrue(alice.engine.table().mine.isEmpty(), "the table did not survive the restart")
alice.act { put(listOf(iou)) }
alice.act { accept() }
pump(alice, bob)
bob.act { accept() }
pump(alice, bob)
assertEquals(Status.REDEEMED, alice.state(iou.id).status)
assertEquals(Status.REDEEMED, bob.state(iou.id).status)
}
private fun handedOver(
debtor: Device,
holder: String,
metadata: Metadata,
id: String = UUID.randomUUID().toString()
): Iou =
handedOver(debtor.signer, holder, metadata, id)
private fun handedOver(
debtor: Signer,
holder: String,
metadata: Metadata,
id: String = UUID.randomUUID().toString()
): Iou =
Ledger.transfer(Ledger.issue(debtor, metadata, id = id), debtor, holder)
}
@@ -0,0 +1,163 @@
package net.helcel.owu.peer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import net.helcel.owu.crypto.JvmSigner
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.store.IouStore
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** The session's timing and plumbing, with a fake air and virtual time. */
@OptIn(ExperimentalCoroutinesApi::class)
class PeerSessionTest {
private fun store() = IouStore(Files.createTempDirectory("iou").toFile())
@Test
fun `an unanswered HELLO is repeated until the peer answers`() = runTest {
val alice = JvmSigner()
val sent = mutableListOf<PeerMessage>()
val session = PeerSession(
ByteArray(8),
PeerEngine(store(), alice, "Alice"),
{ bytes, _ -> sent += PeerMessage.decode(bytes); true },
this
)
advanceTimeBy(100)
assertEquals(1, sent.filterIsInstance<PeerMessage.Hello>().size)
advanceTimeBy(20_000)
val hellos = sent.filterIsInstance<PeerMessage.Hello>()
assertTrue(hellos.size >= 3, "said hello again while unverified: ${hellos.size}")
assertTrue(hellos.all { it.nonce == hellos.first().nonce }, "same nonce every time")
// The peer finally answers: their HELLO, then AUTH over our nonce.
val bob = JvmSigner()
val theirs = PeerEngine(store(), bob, "Bob")
val theirHello = theirs.start().send.single() as PeerMessage.Hello
session.deliver(PeerMessage.encode(theirHello))
val theirAuth = theirs.onMessage(hellos.first()).send.single()
session.deliver(PeerMessage.encode(theirAuth))
advanceTimeBy(100)
assertEquals(PeerSession.Phase.READY, session.state.value.phase)
assertEquals("Bob", session.state.value.peer?.name)
val before = sent.size
advanceTimeBy(30_000)
assertEquals(before, sent.size, "nothing more once verified")
session.gone()
}
@Test
fun `an unanswered hello is not worth saying, a lost action is`() = runTest {
val alice = JvmSigner()
val bob = JvmSigner()
val store = store()
// A link that swallows everything, but still shows us what we said.
val sent = mutableListOf<PeerMessage>()
val session = PeerSession(
ByteArray(8),
PeerEngine(store, alice, "Alice"),
{ bytes, _ -> sent += PeerMessage.decode(bytes); false },
this
)
advanceTimeBy(100)
assertTrue(session.state.value.log.isEmpty(), "a hello nobody answers is ordinary: ${session.state.value.log}")
// Their side answers ours, so the peer is verified even though nothing we send arrives.
val hello = sent.filterIsInstance<PeerMessage.Hello>().first()
val theirs = PeerEngine(store(), bob, "Bob")
session.deliver(PeerMessage.encode(theirs.start().send.single()))
session.deliver(PeerMessage.encode(theirs.onMessage(hello).send.single()))
advanceTimeBy(100)
assertEquals(PeerSession.Phase.READY, session.state.value.phase)
// Now something the user did goes missing: that is worth saying.
val OwU = Ledger.issue(alice, Metadata("1 Coffee"))
store.put(OwU)
session.put(listOf(OwU))
advanceTimeBy(1000)
assertTrue(session.state.value.log.any { it.startsWith("Not delivered") }, session.state.value.log.toString())
assertNull(session.state.value.waiting)
session.gone()
}
/**
* What the screen says it was. A redemption reaches the holder as a gift
* given and a receipt returned, two steps apart, and the debtor as a gift
* taken and a promise closed in the same one - and it is one thing that
* happened, named once, on both phones.
*/
@Test
fun `a redemption is announced once on each side, as a redemption`() = runTest {
val alice = JvmSigner() // holds it, and asks
val bob = JvmSigner() // wrote it, and honours it
val aStore = store()
val bStore = store()
val OwU = Ledger.transfer(Ledger.issue(bob, Metadata("1 Beer")), bob, alice.publicKey)
aStore.put(OwU); bStore.put(OwU)
val aSaid = mutableListOf<Outcome>()
val bSaid = mutableListOf<Outcome>()
lateinit var them: PeerSession
val us = PeerSession(
ByteArray(8) { 1 }, PeerEngine(aStore, alice, "Alice"),
{ bytes, _ -> them.deliver(bytes); true }, this, { aSaid += it })
them = PeerSession(
ByteArray(8) { 2 }, PeerEngine(bStore, bob, "Bob"),
{ bytes, _ -> us.deliver(bytes); true }, this, { bSaid += it })
advanceTimeBy(1_000)
assertEquals(PeerSession.Phase.READY, us.state.value.phase, "handshake: ${us.state.value.log}")
// Alice asks; Bob says yes, as he would from the prompt.
us.offer(listOf(OwU))
advanceTimeBy(1_000)
them.accept()
advanceTimeBy(1_000)
// Not "Gave 1 Beer" and then "1 Beer redeemed": once, and as what it was.
assertEquals(1, aSaid.size, "the asker was told once: $aSaid")
assertEquals(1, bSaid.size, "the debtor was told once: $bSaid")
for (said in listOf(aSaid, bSaid)) {
assertEquals(listOf("1 Beer"), said.single().redeemed.map { it.metadata.title })
assertTrue(said.single().gave.isEmpty() && said.single().got.isEmpty(), "${said.single()}")
}
assertEquals(bob.publicKey, aSaid.single().peer)
us.gone(); them.gone()
}
/** An ordinary gift is a gift, and only the two of them hear about it. */
@Test
fun `giving is announced as given on one side and got on the other`() = runTest {
val alice = JvmSigner()
val bob = JvmSigner()
val aStore = store()
val OwU = Ledger.issue(alice, Metadata("1 Coffee"))
aStore.put(OwU)
val aSaid = mutableListOf<Outcome>()
val bSaid = mutableListOf<Outcome>()
lateinit var them: PeerSession
val us = PeerSession(
ByteArray(8) { 1 }, PeerEngine(aStore, alice, "Alice"),
{ bytes, _ -> them.deliver(bytes); true }, this, { aSaid += it })
them = PeerSession(
ByteArray(8) { 2 }, PeerEngine(store(), bob, "Bob"),
{ bytes, _ -> us.deliver(bytes); true }, this, { bSaid += it })
advanceTimeBy(1_000)
us.offer(listOf(OwU))
advanceTimeBy(1_000)
them.accept()
advanceTimeBy(1_000)
assertEquals(listOf("1 Coffee"), aSaid.single().gave.map { it.metadata.title })
assertEquals(listOf("1 Coffee"), bSaid.single().got.map { it.metadata.title })
us.gone(); them.gone()
}
}
@@ -0,0 +1,74 @@
package net.helcel.owu.peer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import net.helcel.owu.ble.Ble
import net.helcel.owu.ble.Heard
import net.helcel.owu.crypto.Hash
import net.helcel.owu.crypto.JvmSigner
import net.helcel.owu.store.IouStore
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
/** Sessions come and go with presence, or with a HELLO that names its sender. */
@OptIn(ExperimentalCoroutinesApi::class)
class PeerTableTest {
private fun store() = IouStore(Files.createTempDirectory("iou").toFile())
@Test
fun `presence opens one session per beacon and prune closes stale ones`() = runTest {
var now = 0L
val alice = JvmSigner()
val table = PeerTable(this, { PeerEngine(store(), alice, "Alice") }, { _, _, _ -> true }, { now })
val beacon = ByteArray(8) { 7 }
val first = table.heard(Heard.Presence(beacon, -50))
val again = table.heard(Heard.Presence(beacon, -40))
assertSame(first, again)
assertEquals(-40, first.state.value.rssi)
assertEquals(1, table.peers.value.size)
now += PeerTable.GONE_MS - 1
table.prune()
assertEquals(1, table.peers.value.size, "still fresh")
now += 2
table.prune()
assertEquals(0, table.peers.value.size, "gone quiet")
assertEquals(PeerSession.Phase.GONE, first.state.value.phase)
advanceTimeBy(100)
}
@Test
fun `a HELLO from an unheard device opens its session, anything else is dropped`() = runTest {
val alice = JvmSigner()
val bob = JvmSigner()
val sent = mutableListOf<PeerMessage>()
val table = PeerTable(this, { PeerEngine(store(), alice, "Alice") }, { _, bytes, _ -> sent += PeerMessage.decode(bytes); true })
val bobBeacon = Ble.beacon(bob.publicKey)
val hello = PeerEngine(store(), bob, "Bob").start().send.single()
// A LIST_REQUEST from nobody we know: no session.
table.deliver(Heard.Message(bobBeacon, PeerMessage.encode(PeerMessage.Table(bob.publicKey))))
assertNull(table.peers.value[Hash.hex(bobBeacon)])
// A HELLO whose key does not match the sender prefix: no session.
table.deliver(Heard.Message(ByteArray(8) { 1 }, PeerMessage.encode(hello)))
assertEquals(0, table.peers.value.size)
// The real thing.
table.deliver(Heard.Message(bobBeacon, PeerMessage.encode(hello)))
val session = assertNotNull(table.peers.value[Hash.hex(bobBeacon)])
advanceTimeBy(100)
assertEquals(PeerSession.Phase.HANDSHAKE, session.state.value.phase)
assertNotNull(sent.filterIsInstance<PeerMessage.Auth>().firstOrNull(), "answered their HELLO with AUTH: $sent")
assertNull(table.readySession(bob.publicKey), "not verified yet")
table.clear()
advanceTimeBy(100)
}
}
@@ -0,0 +1,82 @@
package net.helcel.owu.store
import net.helcel.owu.crypto.JvmSigner
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import java.util.Base64
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/** A backup is only worth what it can be read back as. */
class BackupTest {
private val alice = JvmSigner()
private val contents = Backup.Contents(
privateKey = Base64.getEncoder().encodeToString(ByteArray(64) { it.toByte() }),
publicKey = alice.publicKey,
name = "Ada",
ious = listOf(Ledger.issue(alice, Metadata("1 Beer"))),
templates = listOf(Template(UUID.randomUUID().toString(), Metadata("1 Coffee"), Ledger.now())),
contacts = listOf(Contact(JvmSigner().publicKey, "Dana")),
)
@Test
fun `what goes in comes back out`() {
val file = Backup.seal(contents, "correct horse".toCharArray())
assertEquals(contents, Backup.open(file, "correct horse".toCharArray()))
}
@Test
fun `the passphrase matters, and nothing readable is left in the file`() {
val file = Backup.seal(contents, "correct horse".toCharArray())
assertFailsWith<BackupException> { Backup.open(file, "Correct horse".toCharArray()) }
assertFailsWith<BackupException> { Backup.open(file, "".toCharArray()) }
// The envelope says how to open it, and nothing else: no titles, no
// names, no keys in the clear.
val text = file.toString(Charsets.UTF_8)
assertTrue(text.contains("PBKDF2"), "the envelope names its own parameters")
// Against the ciphertext itself, not against its base64. Three
// letters turn up in a few thousand random base64 characters often
// enough to fail this test about once in a hundred runs, which is
// exactly often enough to be disbelieved when it matters.
val sealed = Base64.getDecoder().decode(
Regex("\"data\":\"([^\"]*)\"").find(text)!!.groupValues[1]
)
for (secret in listOf("1 Beer", "1 Coffee", "Ada", "Dana", alice.publicKey)) {
assertTrue(!text.substringBefore("\"data\"").contains(secret), "found $secret in the envelope")
assertTrue(sealed.indexOfSub(secret.toByteArray(Charsets.UTF_8)) < 0, "found $secret in the ciphertext")
}
}
@Test
fun `a changed byte is refused, not half-read`() {
val file = Backup.seal(contents, "correct horse".toCharArray())
val text = file.toString(Charsets.UTF_8)
val at = text.indexOf("\"data\":\"") + 10
val tampered = (text.substring(0, at) + (if (text[at] == 'A') 'B' else 'A') + text.substring(at + 1))
.toByteArray(Charsets.UTF_8)
assertFailsWith<BackupException> { Backup.open(tampered, "correct horse".toCharArray()) }
}
@Test
fun `junk and backups from the future are refused by name`() {
assertFailsWith<BackupException> { Backup.open("hello".toByteArray(), "x".toCharArray()) }
assertFailsWith<BackupException> { Backup.open(ByteArray(0), "x".toCharArray()) }
val future = """{"v":99,"app":"owu","kdf":"PBKDF2WithHmacSHA256","rounds":1,"salt":"AA==","nonce":"AA==","data":"AA=="}"""
val why = assertFailsWith<BackupException> { Backup.open(future.toByteArray(), "x".toCharArray()) }
assertTrue(why.message!!.contains("newer"), why.message!!)
}
}
/** Where [needle] starts in this array, or -1. */
private fun ByteArray.indexOfSub(needle: ByteArray): Int {
if (needle.isEmpty() || needle.size > size) return -1
outer@ for (i in 0..size - needle.size) {
for (j in needle.indices) if (this[i + j] != needle[j]) continue@outer
return i
}
return -1
}
@@ -0,0 +1,87 @@
package net.helcel.owu.tools
import net.helcel.owu.ledger.GeoLoc
import net.helcel.owu.ledger.IouJson
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.crypto.JvmSigner
import java.io.File
import kotlin.test.Test
/**
* Not a test: a generator for on-device smoke testing. Set
* OWU_FIXTURE_CREDITOR to a device's public key and OWU_FIXTURE_OUT to a
* directory, and it writes OwUs that device holds, plus a contacts file
* naming the other parties. Does nothing otherwise.
*
* OWU_FIXTURE_CREDITOR=MFkw... OWU_FIXTURE_OUT=/tmp/fx ./gradlew testDebugUnitTest --tests '*FixtureTest*'
*/
class FixtureTest {
@Test
fun generate() {
val creditor = System.getenv("OWU_FIXTURE_CREDITOR") ?: return
val out = File(System.getenv("OWU_FIXTURE_OUT") ?: return).apply { mkdirs() }
val dana = JvmSigner()
val eli = JvmSigner()
// Held by the device, tied to a place.
val coffee = Ledger.issue(
dana, creditor = creditor,
metadata = Metadata("1 Coffee", geoloc = GeoLoc.of(46.5197, 6.6323, 150, "Café du Lac")),
)
// Held by the device, no place.
val hug = Ledger.issue(dana, creditor = creditor, metadata = Metadata("1 Heavy Hug"))
// Seen but not ours: dana owes eli, transferred once.
val ride = Ledger.transfer(Ledger.issue(dana, creditor = eli.publicKey, metadata = Metadata("1 Ride to the airport")), eli, creditor)
// Held by the device, not redeemable for two days / expired yesterday.
val now = Ledger.now()
val cinema = Ledger.issue(dana, creditor = creditor, metadata = Metadata("1 Cinema ticket", notBefore = now + 2 * 86400, notAfter = now + 30 * 86400))
val lunch = Ledger.issue(dana, creditor = creditor, metadata = Metadata("1 Lunch", notAfter = now - 86400))
// Redeemed between others: dana handed it back and eli closed it.
var beer = Ledger.issue(eli, creditor = dana.publicKey, metadata = Metadata("1 Beer"))
beer = Ledger.redeem(Ledger.transfer(beer, dana, eli.publicKey), eli)
for (iou in listOf(coffee, hug, ride, beer, cinema, lunch)) {
File(out, "${iou.id}.json").writeText(IouJson.encode(iou))
}
File(out, "contacts.json").writeText(
"""[{"pub_key":"${dana.publicKey}","name":"Dana"},{"pub_key":"${eli.publicKey}","name":"Eli"}]"""
)
println("fixtures written to $out")
}
}
/** Also not a test: verifies a chain file from a device. Set OWU_VERIFY_FILE. */
class VerifyFileTest {
@Test
fun verify() {
val path = System.getenv("OWU_VERIFY_FILE") ?: return
val iou = IouJson.decode(File(path).readText())
val verdict = Verifier.verify(iou)
println("verify $path -> $verdict")
check(verdict is Verdict.Valid) { "device chain rejected: $verdict" }
}
}
/** Also not a test: decodes the identity QR from a screenshot. Set OWU_QR_PNG. */
class DecodeQrTest {
@Test
fun decode() {
val path = System.getenv("OWU_QR_PNG") ?: return
val img = javax.imageio.ImageIO.read(File(path))
val pixels = img.getRGB(0, 0, img.width, img.height, null, 0, img.width)
val bitmap = com.google.zxing.BinaryBitmap(
com.google.zxing.common.HybridBinarizer(com.google.zxing.RGBLuminanceSource(img.width, img.height, pixels))
)
val hints = mapOf(com.google.zxing.DecodeHintType.TRY_HARDER to true)
val text = com.google.zxing.qrcode.QRCodeReader().decode(bitmap, hints).text
println("qr decoded -> $text")
System.getenv("OWU_QR_OUT")?.let { File(it).writeText(text) }
net.helcel.owu.helper.IdentityCard.decode(text)?.let {
println("qr card -> name='${it.name}' fingerprint=${net.helcel.owu.crypto.Keys.fingerprint(it.key)}")
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '9.3.3' apply false
id 'com.android.library' version '9.3.3' apply false
id 'org.jetbrains.kotlin.android' version '2.4.20' apply false
}
+215
View File
@@ -0,0 +1,215 @@
# OwU: ledger and protocol specification
A serverless, local-first ledger of signed IOUs ("OwUs") exchanged in person over Bluetooth Low Energy.
---
## 1. Concepts
- **Debtor:** who wrote the OwU and owes what it says.
- **Holder:** who currently holds the right to redeem it. The creditor at issue, or whoever it has since been signed over to.
- **Ledger:** the immutable sequence of signed blocks under one `iou_id`. There is no global ledger; each OwU carries its own.
### States
1. **ACTIVE:** signed by its issuer and live on the chain. Every OwU is in this state from the moment it is written until it is redeemed.
2. **REDEEMED:** terminal. The issuer's signature confirms the promise was kept, and nothing may follow.
A **template** is not a state: no chain, no signature, and putting one on a table mints a fresh ACTIVE OwU from it (section 3). There is no state for "waiting on somebody", by design (section 2.5).
### Gates: place and time
An OwU may name a place (point, radius, label) and a redemption window (`not_before`, `not_after`, epoch seconds). Issue refuses a window that closes before it opens; both are optional.
**Neither is enforced, by the protocol or the app.** A window that has passed or a place you are not at is painted red on both sides of the table, and nothing more: only the person who wrote the promise can say whether it still counts, and they say so by accepting or not. Enforcing it would be theatre in any case, since a GPS fix and a block timestamp are both self-reported by the device that wants the answer.
Both are covered by the ISSUE signature, so neither can be edited after the fact.
---
## 2. Cryptography
### 2.1 Keys **[`crypto/Identity.kt`]**
- **Algorithm:** ECDSA over secp256r1 (NIST P-256), `SHA256withECDSA`, DER signatures in the one canonical encoding of section 2.4.
- **Storage:** the app's own storage, sealed with AES-GCM under a key that _is_ in the Android Keystore. The file is worthless off the device; the identity can still be read out by its owner.
- **Why not a Keystore signing key.** Such a key cannot be extracted, so the identity would die with the phone and every OwU anyone holds from you would be impossible to redeem, since only its author can close a promise (2.5). A backup that cannot carry the key is not a backup.
- **What that costs:** the backup file _is_ the identity, and its passphrase (section 4) is the only thing between a copied file and a stolen name.
- **Identity:** the public key, as base64 of the X.509 `SubjectPublicKeyInfo` DER (91 bytes for P-256). Shown to humans as a 16-hex-digit fingerprint: the first 64 bits of its SHA-256.
### 2.2 Payload schema **[`ledger/Model.kt`]**
An OwU is its metadata and a chain of blocks.
```json
{
"iou_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"metadata": {
"display_title": "1 Heavy Hug",
"description": "Whenever you need one. No questions.",
"template_id": "tmpl_hug_01",
"geoloc": {
"lat_e6": 46519700,
"lon_e6": 6632300,
"radius_m": 200,
"label": "Lausanne"
},
"not_before": 1790000000,
"not_after": 1792600000
},
"ledger_chain": [
{
"action": "ISSUE",
"sequence": 0,
"timestamp": 1790000000,
"parent_hash": "0000...0000",
"debtor_pub_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...",
"creditor_pub_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...",
"metadata_hash": "3a7f...",
"signature": "MEQCIFzV8hNz..."
},
{
"action": "TRANSFER",
"sequence": 1,
"timestamp": 1790050000,
"parent_hash": "9c21...",
"transferor_pub_key": "...",
"transferee_pub_key": "...",
"signature": "MEQCIH8XmY9w..."
}
]
}
```
Block types and who signs them:
| `action` | signer | extra fields |
| ---------- | -------------- | ------------------------------------------------------------------------------------ |
| `ISSUE` | debtor | `debtor_pub_key`, `creditor_pub_key`, `metadata_hash` |
| `TRANSFER` | current holder | `transferor_pub_key`, `transferee_pub_key`, and `agreement` for a swap (section 2.6) |
| `REDEEMED` | debtor | `debtor_pub_key` |
**Canonical form.** Signatures and hashes are never computed over the wire JSON. They are computed over a canonical encoding (`crypto/Canonical.kt`): the RFC 8785 subset of objects with keys sorted by UTF-16 code unit, strings, integers, booleans and lists; map entries whose value is null are omitted; floating point is rejected (coordinates are integer micro-degrees). This lets the transport format grow fields without invalidating a single signature.
**What a signature covers.** `canonical({iou_id, sequence, action, timestamp, parent_hash, ...action fields})`. Including `iou_id` means an ISSUE block cannot be replayed as a second OwU under a different id.
**Metadata is signed.** `metadata_hash = SHA-256(canonical(metadata))` is inside the ISSUE block, so the title, the description, the place and the redemption window cannot be edited after issue - neither by the debtor nor by anyone who later holds it.
**Linking is by hash, not by signature.** `parent_hash = SHA-256(canonical(previous block payload + its signature))`, hex. ECDSA signatures are malleable - `(r, s)` and `(r, n - s)` are both valid - so linking by the literal parent signature would let one history exist as two byte-different chains. Hashing the whole previous block gives one head per history. ISSUE uses 64 zeros.
### 2.3 Verification **[`ledger/Verifier.kt`]**
A received chain is accepted only if every rule holds; otherwise it is dropped with the failing block index and reason.
1. **Block 0** is `ISSUE`: `sequence == 0`, `parent_hash == 0...0`, `metadata_hash` matches the metadata, signature verifies under `debtor_pub_key`. Debtor and creditor may be the same key: a blank promise (section 3). Initial state: holder = creditor, status = ACTIVE.
2. **For each block n >= 1:** `sequence == n`; `parent_hash` equals the hash of block n-1; no block may follow a `REDEEMED`; the signature verifies under the block's designated signer, **and is in the one accepted encoding** - minimal DER, low _s_ (section 2.4).
3. **Authorization matrix:**
- `TRANSFER`: status is ACTIVE; `transferor_pub_key` is the current holder; transferee != transferor. Holder becomes transferee. With an `agreement`, section 2.6 applies on top of those three rules.
- `REDEEMED`: status is ACTIVE, `debtor_pub_key` is the OwU's debtor, **and the debtor is the current holder** - a promise is closed by its maker, once it is back in their hands. Status becomes REDEEMED.
Timestamps are not validated against each other; device clocks are not trusted and they are for display only.
### 2.4 Forgery and duplication **[`crypto/Keys.kt`, `store/IouStore.kt`, `peer/PeerEngine.kt`]**
Two things can go wrong with an OwU, and they are not the same kind of problem.
**Forgery is prevented.** Every block names the identity that must have signed it, and section 2.3 verifies each under that key before the chain is kept. Writing an OwU in someone's name, or moving one you do not hold, needs their private key. What the protocol cannot supply is that a key belongs to the person in front of you: that comes from meeting them (section 4).
Signatures are additionally required to be **canonical**: minimal DER with low _s_. ECDSA accepts (r, s) and (r, n - s) alike, so without this rule anyone who has merely _seen_ an OwU could rewrite its head signature - still valid, different block hash - and make two copies that look like a double-spend by their holder. With the rule, a fork means what it says.
**Duplication can only be detected, never prevented.** A holder can sign two competing blocks at the same sequence - the same OwU to C and to D - and no offline system can stop them. Two things catch it:
1. **At the table** (`PeerEngine.adoptTheirs`, `IouStore.compare`): the moment an OwU reaches the other side, in a `TABLE` or riding on an `ACCEPT`, it is checked against the copy they already hold. A history that contradicts theirs, or a head they know has already moved on, is refused - the OwU cannot be accepted and the confirm button is dead. This is the only check that works with the debtor nowhere in sight, and it is why the whole chain travels with every message.
2. **At redemption** (`PeerEngine.given`, on the `GIVE` that brings an OwU home): the debtor merges the presented chain into their own. A fork from a chain they have already redeemed is declined outright; a fork from an open copy is shown as a conflict, and honouring it anyway is the debtor's call. **The debtor is the arbiter**: whichever chain they sign `REDEEMED` on is the one that was honoured, and the other is dead.
No risk score is offered. Counting how far an OwU has travelled was tried and removed: amber on almost everything, silent about whether a double happened, and too late to act on. The table shows who owes it instead, which is the fact you are weighing.
### 2.5 Redemption **[`Ledger.redeem`, `PeerEngine.closeIfHome`]**
**Redeeming an OwU is handing it back to the person who made it.** It is the table of section 3 with one OwU on it whose debtor is the other side: the holder signs an ordinary `TRANSFER` home, and the debtor, now holding their own promise, appends `REDEEMED` - terminal, and valid only when holder and debtor are the same person. The table already collects both consents, so the ledger needs no requested state and no cancel block. A debtor who would rather not simply does not accept; `DECLINE` (section 3) tells the asker so, and touches no chain.
A promise that arrives back with its author closes itself however it came, handed over or swapped (`PeerEngine.closeIfHome`), and the closed chain goes back as a receipt. There is no manual "close", because no path leaves a promise open in its author's hands: your own OwUs are only ever ones that have not left yet, and those are yours to give away or delete.
_(This replaced a two-phase `REDEEM_REQUEST` design that left OwUs frozen in a "requested" state. The handshake is the consent; a separate request was ceremony.)_
### 2.6 Exchange (atomic swap) **[`Ledger.proposeExchange` / `acceptExchange` / `applyExchange`]**
Two holders swap **bundles** in one step: each side puts down one or more OwUs - five beers, two hugs and a surprise trip is one side - and all of them move together or none does. It runs on an **agreement** that both sign:
```json
{
"exchange_id": "...",
"timestamp": 1790060000,
"left": {
"holder_pub_key": "A",
"ious": [
{ "iou_id": "X1", "head_hash": "..." },
{ "iou_id": "X2", "head_hash": "..." }
],
"signature": "...by A"
},
"right": {
"holder_pub_key": "B",
"ious": [{ "iou_id": "Y", "head_hash": "..." }],
"signature": "...by B"
}
}
```
Each side's `ious` are sorted by `iou_id` in the signed bytes, so both parties sign the same canonical form whatever order they assembled the bundle in.
1. **Propose:** A, holding every OwU on the left, builds the agreement naming each of them at its current head and each of B's (as A last saw them) at its current head, and signs the core (everything except the two signatures).
2. **Accept:** B, holding every OwU on the right, checks that each is still at the stated head and that B is the named holder, and signs the same core.
3. **Apply:** with both signatures present, a `TRANSFER` carrying the agreement is appended to _every_ chain named. On A's OwUs the block is signed by A, on B's by B - and the block's signature **is** that side's agreement signature, so once both have signed, either party can append every block. Because the agreement is what one signature has to commit to, such a block signs `signingBytes()` rather than its own payload; that is the only case where the two differ.
On top of the three `TRANSFER` rules, the verifier accepts an agreement-bearing block on chain Z only if: the agreement names Z on one side and not on the other; that side's holder is the block's `transferor_pub_key` and the other side's holder is its `transferee_pub_key`; Z's pinned `head_hash` in that side equals the block's `parent_hash`; the block's timestamp equals the agreement's; the other side is not empty; both signatures are present and verify; and the block's signature equals this side's agreement signature.
Pinning **every** OwU to a head hash is what makes the bundle one deal: if any of them moves first the agreement is void, and no partial swap can land.
---
## 3. Meeting in person **[`peer/`, `ble/`]**
There is no synchronisation. Two devices only ever exchange messages as steps of something a user asked for, and every message carries the whole chain it concerns, so the receiver verifies it from the ISSUE block.
To the user there are two things to do with someone: put something on the **table**, and **redeem**. The table has two sides; each person puts a bundle of OwUs on their side or leaves it empty, sees what the other put, and says yes. Nothing moves until both have said yes to the _same_ table. Everything that moves an OwU is this shape: a gift is my bundle against their nothing, a new promise is one I write and put down, a swap is a bundle each. A bundle may hold several copies of one template - five beers are five separate OwUs that travel together.
Both of these reach a person wherever they are in the app, by a prompt: being asked to a table, and being asked to honour a promise, are the two interruptions worth making. Everything else waits until somebody looks.
| Handshake | Steps | Who signs what |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Invite** | `INVITE{key}` on opening a table | Nothing signed, no ledger state, no answer expected. Without it, opening a table reaches nobody not already on the right screen. Refused before authentication like any message, and ignored unless the sender is a contact. |
| **Decline** | `DECLINE{key, to}`, `to` being `INVITE` or `TABLE` | Nothing signed. Refusing the **table** clears it on both sides - OwUs stay with their holders, a minted OwU that never left is discarded - so nothing sits refused. |
| **Table** | `TABLE{key, ious[]}` either way, any number of times -> `ACCEPT{key, deal, ious[], agreement?}` from each side -> `GIVE{ious[]}` when only one side has anything | The `deal` is a hash of every OwU on the table, each pinned to its current head, so both sides compute the same string and a yes that crosses a change is refused. **One side only:** on the second yes its holder signs a `TRANSFER` per OwU and sends them all in one `GIVE`. **Both sides:** the side holding the lowest OwU id puts a half-signed `ExchangeAgreement` (section 2.6) covering both bundles in its `ACCEPT`; the other countersigns, applies the agreement-bearing `TRANSFER` to every OwU, and returns the complete agreement, which the first side applies too. **Nothing on either side:** nothing to accept. |
| **Redeem** | the Table above, with promises of theirs on my side -> `GIVE{ious[]}` -> `REDEEMED{iou}` per OwU | it is a gift back to the person who owes them: the holder's `TRANSFER` sends each home, and the debtor appends `REDEEMED` to each on receipt and returns the closed chains as receipts (section 2.5) |
**A promise you write is a template, not an OwU** (`store/Template`, in `files/templates/`). No chain, no signature; it stays until deleted. Putting one on a table _mints_ an OwU: new `iou_id`, ISSUE signed there and then, debtor = creditor = you. So one "1 Beer" becomes as many beers as you hand out. A minted OwU that never leaves is deleted again, which is safe because it is still a lone ISSUE block in its author's hands and nobody else can hold it. The verifier allows debtor = creditor; the app never offers to redeem an OwU from yourself.
### 3.1 One carrier: a connection **[`ble/Link.kt`, `ble/Frames.kt`]**
Nothing to pair and nothing to tap, but a connection underneath.
- **Finding each other.** One connectable legacy advertisement: our service UUID (`0000f077-...`, 16-bit aliased so it fits in 31 bytes) and the device's 8-byte beacon, the first 8 bytes of SHA-256 over the public key's DER. Every device scans for that service. The peer list is the same whatever the radios can do.
- **A connection is opened** when a contact is picked to trade with, or when an OwU is asked to be redeemed from anywhere in the app, and dropped when that is done. Each device runs both a GATT server and a client, so either side can dial; whoever dials writes to the characteristic and the other notifies back on it.
- **Framing** (`ble/Frames.kt`, pure and unit-tested): `length (4) + sender beacon (8) + payload`, written in pieces of at most `min(MTU - 3, 512)` bytes - an attribute value is 512 bytes however large the MTU says it is, and Android throws above that.
- The beacon rides in every frame because a connection says which _device_ is talking, not which identity, and the side that accepted the connection has not yet heard an advertisement from the side that dialled.
- **Handshake:** on a new connection both sides send `HELLO{identity, nonce}` and answer the other's with `AUTH{signature}` over `"owu-auth-v1\n<their nonce>\n<my key>"`. Until that signature checks out, nothing else is accepted from them - but a message arriving before it is answered with our own `HELLO` rather than dropped, or a side whose radio restarted would leave the other waiting for ever on an answer it will never send. A new nonce from a verified peer means they restarted, so this side does too.
- **Foreground only.** The app puts the device on the air while it is open and takes it off when it is paused, so an ask can be made and answered from any screen but never while the app is away. A peer unheard of for 12 seconds is dropped - being heard advertising is the only evidence of presence, since a peer that sleeps or is killed often leaves its connection behind with no disconnect ever arriving.
- **Permissions:** `BLUETOOTH_SCAN` (`neverForLocation`), `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT`; `BLUETOOTH` and `BLUETOOTH_ADMIN` are declared `maxSdkVersion="30"` for API 28-30, where scanning also needs location.
- **Nothing on the connection is encrypted.** Every block is signed, so a listener in range can forge nothing and alter nothing, but it can _read_ what two people trade: titles, descriptions, places, keys. The advertisement is public as well, and its beacon is stable, so a phone can be recognised by anyone watching for one.
---
## 4. Storage **[`store/`]**
- One JSON file per OwU under `files/ious/`, one per template under `files/templates/`, plus `files/contacts.json` and `files/met.json`. Written via temp-file-and-rename. Small enough to hold in memory; exposed to the UI as `StateFlow`s.
- **Merge rule for an incoming chain** (`IouStore.merge`): verify first; a new id is added; a longer chain whose prefix equals ours replaces ours; a shorter or equal one is ignored; anything else is a **fork**, reported and not stored (section 2.4). Every handshake in section 3 stores what it receives through this rule.
- **Backup** (`store/Backup.kt`, Settings > Backup) writes the whole phone to one file: the identity (both halves of the key), the display name, every OwU, every template and the address book. The file is JSON whose only readable part is how to open it - `{v, app, kdf, rounds, salt, nonce, data}` - with the contents under AES-256-GCM, keyed by PBKDF2-HMAC-SHA256 over a passphrase the user picks (210 000 rounds, 16-byte salt). A wrong passphrase and a tampered byte are the same event to GCM and get the same message.
- Restoring puts OwUs back through the merge rule of this section, so a restore onto a phone that has moved on since cannot rewind anything.
- Taking on the backup's identity is a separate question the app asks; it replaces the phone's own, and promises made under the old one could no longer be closed from there. OwUs are restored either way.
- There is no single-OwU import or share: a chain says who holds it, so sending its text to somebody changes nothing. Section 3 is how OwUs move.
- **Who a key is** comes in two grades, which is the whole point of the address book.
- **Met** (`files/met.json`): the name a key gave itself in its `HELLO`, kept once `AUTH` proved it holds that key. Nobody vouched for the _name_, so it only saves a screen from being a wall of hex.
- **A contact** (`files/contacts.json`): a key you have met and named yourself, by scanning their code or entering it. This is what lets an OwU keep its meaning second-hand: the signature proves a key promised something, the contact says whose key it is. So an OwU owed by someone other than whoever offers it reads as _"owed by Dana, in your contacts"_, or _"owed by ce21 9047 de70 0252, whom you have not met"_ in red.
- A peer introducing itself with a name you know, on a key you do not, is called out on the peer screen.
- **Permissions:** `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION` (the place gate, asked when first needed) and `CAMERA` (QR scanning, asked by the scanner). Bluetooth is section 3.1.
+9
View File
@@ -0,0 +1,9 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=false
kotlin.code.style=official
android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=false
android.r8.strictFullModeForKeepRules=false
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+14
View File
@@ -0,0 +1,14 @@
iOweU ("I owe you") keeps track of who owes whom, between people who actually meet.
Write down a promise - a beer, a lift to the airport, an hour of help - and hand it to someone. It is signed with a key that never leaves your phone, so what they hold is evidence that you wrote it. They can redeem it with you, or pass it on to somebody else, and it still reads as yours.
There is no account, no server and no internet permission. Promises move over Bluetooth when two phones are next to each other.
* Signed promises: each one carries its own history, from who wrote it to whoever holds it now
* Trade: each side puts in a promise or nothing, both confirm, and a swap is all-or-nothing
* Redeem: press Redeem and the person who owes it is prompted. If they are not around, the ask waits and goes out by itself when they turn up
* Templates: write "1 Beer" once and hand out a fresh, separately signed promise every round
* Tie a promise to a place or a time (never enforced, just as hint), this way the person who made it can decide whether it still counts
* Trust people by scanning the code on their profile and naming them; you can only trade with someone you have named/trusted
* Small & Fast
* 100% Free and Open Source software, with no proprietary dependencies
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+1
View File
@@ -0,0 +1 @@
I Owe You (OwU in short) keep track of who owes whom
+1
View File
@@ -0,0 +1 @@
iOweU
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["sora/renovate-config"]
}
+21
View File
@@ -0,0 +1,21 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url 'https://jitpack.io' }
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "owu"
include ':app'