init
This commit is contained in:
@@ -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'
|
||||
}
|
||||
Vendored
+21
@@ -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)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user