init
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>.
|
||||||
@@ -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
|
||||||
@@ -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/>.
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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%
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1 @@
|
|||||||
|
I Owe You (OwU in short) keep track of who owes whom
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
iOweU
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["sora/renovate-config"]
|
||||||
|
}
|
||||||
@@ -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'
|
||||||