This commit is contained in:
2026-09-26 22:23:16 +02:00
commit 9d57a79c66
98 changed files with 11040 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Reading the geoloc gate on a place-bound OwU. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Scanning a contact's identity QR code. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Talking to a nearby device. Location is never derived from scans. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-feature android:name="android.hardware.location" android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Owu"
android:label="${PUBLIC_NAME}"
android:supportsRtl="true">
<activity
android:name=".activity.MainScreen"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
The scanner comes from a library whose own manifest pins it to
sensorLandscape, so it turned sideways the moment it opened.
"behind" means the orientation of the screen it was opened from,
which is the only sensible answer: the app is held one way and the
camera should be held the same way.
-->
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="behind"
tools:ignore="DiscouragedApi"
tools:replace="android:screenOrientation" />
</application>
</manifest>
@@ -0,0 +1,238 @@
package net.helcel.owu.activity
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.AlertDialog
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.owu.R
import net.helcel.owu.crypto.Identity
import net.helcel.owu.crypto.Keys
import net.helcel.owu.helper.toast
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.store.Backup
import net.helcel.owu.store.BackupException
import net.helcel.owu.store.Repo
import java.time.LocalDate
import java.util.Base64
/**
* Settings' backup corner: write everything this phone is to a file, and read
* one back - on this phone or the next one.
*/
@Composable
fun BackupSection() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var passphraseFor by remember { mutableStateOf<Uri?>(null) } // writing
var restoreFrom by remember { mutableStateOf<Uri?>(null) } // reading
var takeOver by remember { mutableStateOf<Backup.Contents?>(null) }
val create = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
passphraseFor = uri
}
val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
restoreFrom = uri
}
fun write(uri: Uri, passphrase: String) {
scope.launch {
val trouble = withContext(Dispatchers.IO) {
runCatching {
val (priv, pub) = Repo.signer.export()
val contents = Backup.Contents(
privateKey = Base64.getEncoder().encodeToString(priv),
publicKey = Base64.getEncoder().encodeToString(pub),
name = Repo.myName(context),
ious = Repo.store.ious.value.values.toList(),
templates = Repo.store.templates.value.values.toList(),
contacts = Repo.store.contacts.value,
)
val bytes = Backup.seal(contents, passphrase.toCharArray())
context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(bytes) }
?: error("could not write there")
}.exceptionOrNull()
}
context.toast(
if (trouble == null) context.getString(R.string.backup_written)
else trouble.message ?: context.getString(R.string.backup_failed)
)
}
}
fun read(uri: Uri, passphrase: String) {
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("could not read that file")
Backup.open(bytes, passphrase.toCharArray())
}
}
result.fold(
onSuccess = { contents ->
// OwUs, templates and names come back whatever happens;
// the identity is a separate question, since taking it on
// means giving up the one this phone has.
restore(contents)
val mine = Keys.decode(contents.publicKey)?.let { Base64.getEncoder().encodeToString(it.encoded) }
if (mine == Repo.me) context.toast(context.getString(R.string.backup_restored))
else takeOver = contents
},
onFailure = {
context.toast(
(it as? BackupException)?.message ?: context.getString(R.string.backup_failed)
)
},
)
}
}
// --- dialogs -----------------------------------------------------------
passphraseFor?.let { uri ->
PassphraseDialog(
title = stringResource(R.string.backup_export),
hint = stringResource(R.string.backup_passphrase_hint),
confirmTwice = true,
onDismiss = { passphraseFor = null },
) { passphrase ->
passphraseFor = null
write(uri, passphrase)
}
}
restoreFrom?.let { uri ->
PassphraseDialog(
title = stringResource(R.string.backup_restore),
hint = stringResource(R.string.backup_passphrase_ask),
confirmTwice = false,
onDismiss = { restoreFrom = null },
) { passphrase ->
restoreFrom = null
read(uri, passphrase)
}
}
takeOver?.let { contents ->
AlertDialog(
onDismissRequest = { takeOver = null },
title = { Text(stringResource(R.string.backup_identity_title)) },
text = { Text(stringResource(R.string.backup_identity_text, Keys.fingerprint(contents.publicKey))) },
confirmButton = {
TextButton(onClick = {
takeOver = null
runCatching {
PeerManager.stop()
Repo.adopt(
Identity.replace(
context,
Base64.getDecoder().decode(contents.privateKey),
Base64.getDecoder().decode(contents.publicKey),
)
)
Repo.setMyName(context, contents.name)
}.fold(
onSuccess = { context.toast(context.getString(R.string.backup_identity_taken)) },
onFailure = { context.toast(it.message ?: context.getString(R.string.backup_failed)) },
)
}) { Text(stringResource(R.string.backup_identity_take)) }
},
dismissButton = {
TextButton(onClick = { takeOver = null }) { Text(stringResource(R.string.backup_identity_keep)) }
},
)
}
// --- the two buttons, side by side -------------------------------------
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedButton(
onClick = { create.launch("owu-backup-${LocalDate.now()}.json") },
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.backup_export)) }
Spacer(Modifier.size(12.dp))
OutlinedButton(
onClick = { pick.launch(arrayOf("application/json", "application/octet-stream", "*/*")) },
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.backup_restore)) }
}
}
/** Puts back everything but the identity, through the ordinary merge rules. */
private fun restore(contents: Backup.Contents) {
contents.ious.forEach { Repo.store.merge(it) }
contents.templates.forEach { Repo.store.putTemplate(it) }
contents.contacts.forEach { Repo.store.putContact(it) }
}
/** Asks for a passphrase, twice when one is being chosen rather than recalled. */
@Composable
private fun PassphraseDialog(
title: String,
hint: String,
confirmTwice: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var first by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
val ready = first.length >= 8 && (!confirmTwice || first == again)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
Text(
hint, style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
)
OutlinedTextField(
value = first, onValueChange = { first = it },
label = { Text(stringResource(R.string.backup_passphrase)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),
modifier = Modifier.padding(top = 12.dp),
)
if (confirmTwice) {
OutlinedTextField(
value = again, onValueChange = { again = it },
label = { Text(stringResource(R.string.backup_passphrase_again)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),
modifier = Modifier.padding(top = 8.dp),
)
}
}
},
confirmButton = {
TextButton(enabled = ready, onClick = { onConfirm(first) }) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@@ -0,0 +1,71 @@
package net.helcel.owu.activity
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothDisabled
import androidx.compose.material.icons.automirrored.filled.BluetoothSearching
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import net.helcel.owu.R
import net.helcel.owu.ble.BlePermissions
import net.helcel.owu.helper.toast
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerManager.Radio
/** Top-bar Bluetooth state. Tap: request permission, or ask the system to enable. */
@Composable
fun BluetoothAction() {
val context = LocalContext.current
val radio by PeerManager.radio.collectAsState()
val onAir by PeerManager.state.collectAsState()
// Result unused: the adapter broadcast starts us.
val enable = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
val permissions = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
PeerManager.resume(context)
if (granted.values.all { it } && PeerManager.radio.value == Radio.OFF) {
enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
}
val live = radio == Radio.ON && onAir.active
val (icon, label) = when {
live -> Icons.Default.Bluetooth to R.string.bluetooth_on
radio == Radio.ON || radio == Radio.TURNING_ON ->
Icons.AutoMirrored.Filled.BluetoothSearching to R.string.bluetooth_starting
else -> Icons.Default.BluetoothDisabled to R.string.bluetooth_off
}
IconButton(onClick = {
when (radio) {
Radio.UNSUPPORTED -> context.toast(context.getString(R.string.bluetooth_unsupported))
Radio.NO_PERMISSION -> permissions.launch(BlePermissions.required().toTypedArray())
Radio.OFF -> runCatching { enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
// Fallback when the enable prompt is missing.
.onFailure { context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }
Radio.TURNING_ON -> Unit
Radio.ON -> {
if (!live) PeerManager.resume(context)
context.toast(onAir.error ?: context.getString(label))
}
}
}) {
Icon(
icon,
contentDescription = stringResource(label),
tint = LocalContentColor.current.copy(alpha = if (live) 1f else 0.6f),
)
}
}
@@ -0,0 +1,365 @@
package net.helcel.owu.activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.crypto.Keys
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.helper.clipboardText
import net.helcel.owu.helper.copyToClipboard
import net.helcel.owu.helper.toast
import net.helcel.owu.store.Contact
import net.helcel.owu.store.Repo
@Composable
fun IdentityScreen(nav: NavHostController) {
val context = LocalContext.current
val contacts by Repo.store.contacts.collectAsState()
val onAir by PeerManager.state.collectAsState()
var name by remember { mutableStateOf(Repo.myName(context)) }
val card = remember(name) { IdentityCard(name = name.trim(), key = Repo.me).encode() }
// A scanned or pasted card is confirmed (and named) before it is kept.
var pending by remember { mutableStateOf<IdentityCard?>(null) }
var showManual by remember { mutableStateOf(false) }
// A name is yours to choose and yours to change; the pencil on the row
// opens it, since the row itself now goes to their table.
var renaming by remember { mutableStateOf<Contact?>(null) }
// Passing one on: the same card this screen shows for yourself, for them.
var showing by remember { mutableStateOf<Contact?>(null) }
fun offer(text: String?) {
val c = text?.let { IdentityCard.decode(it) }
?: text?.trim()?.takeIf { Keys.decode(it) != null }?.let { IdentityCard(name = "", key = it) }
if (c == null) context.toast(context.getString(R.string.contact_unreadable))
else if (c.key == Repo.me) context.toast(context.getString(R.string.contact_is_you))
else pending = c
}
val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> result.contents?.let { offer(it) } }
fun scan() = scanner.launch(ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt(context.getString(R.string.scan_prompt))
setBeepEnabled(false)
// Held the way the app is held. Unlocked, it follows the sensor and
// turns sideways while you are lining a code up.
setOrientationLocked(true)
})
pending?.let { c ->
var contactName by remember(c) { mutableStateOf(c.name) }
AlertDialog(
onDismissRequest = { pending = null },
title = { Text(stringResource(R.string.contact_add)) },
text = {
Column {
Text(Keys.fingerprint(c.key), fontFamily = FontFamily.Monospace)
OutlinedTextField(
value = contactName, onValueChange = { contactName = it },
label = { Text(stringResource(R.string.name)) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(enabled = contactName.isNotBlank(), onClick = {
Repo.store.putContact(Contact(c.key, contactName.trim()))
pending = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = { pending = null }) { Text(stringResource(R.string.cancel)) } },
)
}
renaming?.let { c ->
var name by remember(c) { mutableStateOf(c.name) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text(stringResource(R.string.contact_rename)) },
text = {
Column {
Text(Keys.fingerprint(c.publicKey), fontFamily = FontFamily.Monospace)
OutlinedTextField(
value = name, onValueChange = { name = it },
label = { Text(stringResource(R.string.name)) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = {
Repo.store.putContact(Contact(c.publicKey, name.trim()))
renaming = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = { renaming = null }) { Text(stringResource(R.string.cancel)) } },
)
}
// A contact's card for somebody else to scan, the same shape as your own.
// The name travels as a suggestion; the key is what checks their signatures.
//
// A plain Dialog, not an AlertDialog: M2's lays its text slot out by first
// and last text baseline, and a QR code has neither, so a slot starting
// with one measures to a negative height and throws.
showing?.let { c ->
Dialog(onDismissRequest = { showing = null }) {
Card(shape = MaterialTheme.shapes.medium, elevation = 8.dp) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp),
) {
Text(c.name, style = MaterialTheme.typography.h6)
QrCode(
remember(c) { IdentityCard(name = c.name, key = c.publicKey).encode() },
modifier = Modifier.padding(top = 16.dp).size(240.dp),
)
Text(
Keys.fingerprint(c.publicKey),
style = MaterialTheme.typography.caption,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 8.dp),
)
TextButton(
onClick = { showing = null },
modifier = Modifier.align(Alignment.End).padding(top = 8.dp),
) { Text(stringResource(R.string.ok)) }
}
}
}
}
if (showManual) {
var key by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showManual = false },
title = { Text(stringResource(R.string.contact_add_manually)) },
text = {
OutlinedTextField(
value = key, onValueChange = { key = it },
label = { Text(stringResource(R.string.field_key)) },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = { showManual = false; offer(key) }) { Text(stringResource(R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showManual = false; offer(context.clipboardText()) }) {
Text(stringResource(R.string.contact_from_clipboard))
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.identity_title)) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OutlinedTextField(
value = name,
onValueChange = { name = it; Repo.setMyName(context, it.trim()) },
label = { Text(stringResource(R.string.field_your_name)) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
QrCode(card, modifier = Modifier.padding(top = 16.dp).size(240.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
Text(
Keys.fingerprint(Repo.me),
style = MaterialTheme.typography.h6,
fontFamily = FontFamily.Monospace,
)
IconButton(onClick = {
context.copyToClipboard("iou key", Repo.me)
context.toast(context.getString(R.string.copied))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy_key))
}
}
// The two ways to become a contact, at the head of the list they
// join rather than below however many people you already know.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 4.dp),
) {
Text(
stringResource(R.string.contacts),
style = MaterialTheme.typography.h6,
modifier = Modifier.weight(1f),
)
// A camera and a plus. A QR glyph here was indistinguishable
// from the one on every row below, which *shows* a code rather
// than reads one; a key says "key" only to us.
IconButton(onClick = { scan() }) {
Icon(Icons.Default.PhotoCamera, contentDescription = stringResource(R.string.contact_scan))
}
IconButton(onClick = { showManual = true }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.contact_add_manually))
}
}
Divider()
if (contacts.isEmpty()) {
Text(
stringResource(R.string.no_contacts),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
}
// Who is on the air, read once: the rows say it and the sort uses
// it, and two readings of the same thing drift apart.
val here = onAir.peers.mapNotNull { (hex, session) ->
key(hex) {
val ss by session.state.collectAsState()
hex.takeIf { ss.phase != PeerSession.Phase.GONE }
}
}.toSet()
// Here now first, then the rest, alphabetical within each, so a
// name does not wander far when somebody arrives or leaves.
contacts.sortedWith(
compareByDescending<Contact> { Ble.beaconHex(it.publicKey) in here }
.thenBy { it.name.lowercase() }
).forEach { c ->
ContactRow(
contact = c,
here = Ble.beaconHex(c.publicKey) in here,
onTrade = { nav.navigate("peer/${Ble.beaconHex(c.publicKey)}") },
onShare = { showing = c },
onRename = { renaming = c },
onDelete = { Repo.store.removeContact(c.publicKey) },
)
Divider()
}
}
}
}
/**
* One person you have named: name, key, whether they are in earshot, and the
* button that opens the table with them.
*
* The only door to a trade: you cannot hand a promise to somebody you have not
* named. Redeeming is not a trade and does not come through here - what
* somebody owes you is asked for from the OwU itself, on any screen.
*/
@Composable
private fun ContactRow(
contact: Contact,
here: Boolean,
onTrade: () -> Unit,
onShare: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onRename)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
// A dot beside the name rather than a line of prose under it:
// being here is a property of the person, and it reads at a
// glance down a list where three lines apiece did not. Lit in the
// theme's own colour when they are on the air, all but out when
// they are not - and it keeps its words for the screen reader.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(contact.name, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
Icon(
Icons.Default.Circle,
contentDescription = stringResource(if (here) R.string.contact_here else R.string.contact_away),
tint = if (here) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.2f),
modifier = Modifier.padding(start = 8.dp).size(10.dp),
)
}
Text(
Keys.fingerprint(contact.publicKey),
style = MaterialTheme.typography.caption, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
// Greyed and dead until they are actually on the air: a table needs
// both of you, and there is nothing useful to open without them.
IconButton(onClick = onTrade, enabled = here) {
Icon(
Icons.Default.SwapHoriz,
contentDescription = stringResource(R.string.contact_trade),
tint = if (here) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.3f),
)
}
IconButton(onClick = onShare) {
Icon(Icons.Default.QrCode2, contentDescription = stringResource(R.string.contact_share))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
}
}
}
@@ -0,0 +1,315 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.helper.Gates
import net.helcel.owu.helper.formatTime
import net.helcel.owu.ledger.Block
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.TimeGate
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
/** One OwU: what it is, where it stands, its history - and, when somebody
* else owes it, the button that asks them to redeem it. */
@Composable
fun IouDetailScreen(nav: NavHostController, id: String) {
val context = LocalContext.current
val ious by Repo.store.ious.collectAsState()
// Subscribed, never read: Repo.nameOf() is a plain lookup, so without this
// nothing here would notice a contact being renamed.
@Suppress("UNUSED_VARIABLE") val contacts by Repo.store.contacts.collectAsState()
val iou = ious[id]
if (iou == null) {
// Gone. Leaving is an effect, not part of drawing: popping from
// composition ran again next frame and took the screen underneath too,
// leaving an empty NavHost and a blank screen.
LaunchedEffect(id) { nav.up() }
return
}
val verdict = remember(iou) { Verifier.verify(iou) }
val state = verdict.stateOrNull
val me = Repo.me
var showDelete by remember { mutableStateOf(false) }
// --- dialogs -----------------------------------------------------------
if (showDelete) {
AlertDialog(
onDismissRequest = { showDelete = false },
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.delete_iou_confirm)) },
confirmButton = {
// Removing is enough: the screen sees it gone and leaves.
// Popping here too was the second pop that emptied the stack.
TextButton(onClick = { Repo.store.remove(iou.id); showDelete = false }) {
Text(stringResource(R.string.delete))
}
},
dismissButton = { TextButton(onClick = { showDelete = false }) { Text(stringResource(R.string.cancel)) } },
)
}
// --- screen ------------------------------------------------------------
Scaffold(
topBar = {
TopAppBar(
title = { Text(iou.metadata.title) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { showDelete = true }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
if (state == null) {
val v = verdict as Verdict.Invalid
Text(stringResource(R.string.invalid_chain, v.sequence, v.reason), color = MaterialTheme.colors.error)
return@Column
}
// What was written with it, before the bookkeeping: the part read.
iou.metadata.description?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.body1,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
Field(stringResource(R.string.field_status), statusLabel(state.status))
Field(stringResource(R.string.role_debtor), Repo.nameOf(state.debtor), verified = state.debtor)
Field(stringResource(R.string.role_holder), Repo.nameOf(state.holder))
if (iou.metadata.hasWindow) {
Field(
stringResource(R.string.field_window),
listOfNotNull(
iou.metadata.notBefore?.let { stringResource(R.string.window_from, formatTime(it)) },
iou.metadata.notAfter?.let { stringResource(R.string.window_until, formatTime(it)) },
).joinToString(" "),
)
}
iou.metadata.geoloc?.let { g ->
Field(
stringResource(R.string.field_geoloc),
(g.label?.let { "$it · " } ?: "") + "%.5f, %.5f · %d m".format(g.latitude, g.longitude, g.radiusM),
)
}
// Redeeming is handing it back to whoever wrote it. Your own
// promise closes itself on coming home, so this is only ever
// somebody else's, in your hands.
if (state.status == Status.ACTIVE && state.holder == me && state.debtor != me) {
// Greyed when late, early or elsewhere, and still pressable:
// none of that decides anything. Only the one who wrote it can.
var away by remember(iou.id) { mutableStateOf(false) }
LaunchedEffect(iou.id) { away = Gates.outOfPlace(context, iou) }
val off = iou.metadata.timeGate() != TimeGate.Open || away
// An ask already out: show it rather than a button that would
// only ask again. Silence is the one thing not to do.
val onAir by PeerManager.state.collectAsState()
val session = onAir.peers[Ble.beaconHex(state.debtor)]
val ask = askOutstanding(session, iou.id)
// Asked for, kept until it is on their table: until then they
// are still to be found. Its own state, and it must look like
// one, or the button appears to have done nothing.
val parked = TradeIntent.offer?.takeIf { it.iouId == iou.id } != null
if (ask != null || parked) {
AskStatus(
who = Repo.nameOf(state.debtor),
looking = ask == null,
undelivered = ask?.undelivered != null,
onStop = {
TradeIntent.offer = null
session?.put()
},
)
} else Button(
// Earshot is not this button's business: the ask is written
// down and [PendingAsk] finds them, connects and sends it.
// Nowhere to go, nothing to arrange.
onClick = { TradeIntent.offer = TradeIntent.Offer(state.debtor, iou.id) },
colors = if (!off) ButtonDefaults.buttonColors() else ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.38f),
),
elevation = if (off) ButtonDefaults.elevation(0.dp, 0.dp, 0.dp) else ButtonDefaults.elevation(),
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
) { Text(stringResource(R.string.action_redeem)) }
}
Text(
stringResource(R.string.history),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 24.dp, bottom = 4.dp),
)
Divider()
// Handing an OwU back to its author and the author closing it are
// one thing happening - redeeming - so the pair is shown once.
iou.chain.filterIndexed { i, block ->
!(block is Block.Transfer && block.transferee == debtorOf(iou) && iou.chain.getOrNull(i + 1) is Block.Redeemed)
}.forEach { block ->
HistoryRow(iou, block)
Divider()
}
Text(
iou.id,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
@Composable
private fun Field(label: String, value: String, verified: String? = null) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
label,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(width = 96.dp, height = 20.dp),
)
Text(value, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Medium)
verified?.let { KnownMark(it) }
}
}
/** The one who made the promise, and so the only one who can close it. */
private fun debtorOf(iou: Iou): String? = (iou.chain.firstOrNull() as? Block.Issue)?.debtor
@Composable
private fun HistoryRow(iou: Iou, block: Block) {
// A promise is always written to oneself, so there is no "to" worth
// saying; and giving it back to its author is redeeming it, which is the
// name this and the closing that follows it go by.
val debtor = debtorOf(iou)
val handedBack = iou.chain.getOrNull(block.sequence - 1) as? Block.Transfer
val line = when (block) {
is Block.Issue -> stringResource(R.string.hist_issued, Repo.nameOf(block.debtor))
// A swap says so; a plain hand-over reads by where it went.
is Block.Transfer -> when {
block.agreement != null ->
stringResource(R.string.hist_exchanged, Repo.nameOf(block.transferor), Repo.nameOf(block.transferee))
block.transferee == debtor -> stringResource(R.string.hist_redeemed, Repo.nameOf(block.transferor))
else ->
stringResource(R.string.hist_transferred, Repo.nameOf(block.transferor), Repo.nameOf(block.transferee))
}
// Normally this closes a hand-back and takes its name; a chain that
// reaches this state another way still gets a line of its own.
is Block.Redeemed ->
if (handedBack != null && handedBack.transferee == debtor)
stringResource(R.string.hist_redeemed, Repo.nameOf(handedBack.transferor))
else stringResource(R.string.hist_closed, Repo.nameOf(block.debtor))
}
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(line, style = MaterialTheme.typography.body1)
Text(
"#${block.sequence} · ${formatTime(block.timestamp)}",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
}
/**
* The state of an ask already sent to this OwU's debtor, or null when there
* is none: my side of their table holds exactly this OwU, and I have said
* yes, so the only thing left is their answer.
*/
@Composable
private fun askOutstanding(session: PeerSession?, iouId: String): PeerSession.State? {
if (session == null) return null
val ss by session.state.collectAsState()
val mine = ss.table.mine
return ss.takeIf { mine.size == 1 && mine[0].id == iouId && ss.table.accepted }
}
/** Looking for them, waiting on them, or unable to reach them - and a way to stop any of it. */
@Composable
private fun AskStatus(who: String, looking: Boolean, undelivered: Boolean, onStop: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (looking) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
}
Text(
stringResource(
when {
looking -> R.string.redeem_looking
undelivered -> R.string.redeem_unreachable
else -> R.string.redeem_waiting
},
who,
),
style = MaterialTheme.typography.body1,
color = if (undelivered && !looking) MaterialTheme.colors.error else MaterialTheme.colors.onSurface,
)
}
TextButton(onClick = onStop, modifier = Modifier.padding(top = 4.dp)) {
Text(stringResource(R.string.redeem_stop))
}
}
}
@@ -0,0 +1,226 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Card
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.BuildConfig
import net.helcel.owu.R
import net.helcel.owu.ledger.Block
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouState
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Template
import net.helcel.owu.store.Repo
@Composable
fun IouListScreen(nav: NavHostController) {
val ious by Repo.store.ious.collectAsState()
// Subscribed, never read: names come from contacts through Repo.nameOf(),
// which is a plain lookup, so without this nothing here would notice a
// contact being added or renamed.
@Suppress("UNUSED_VARIABLE") val contacts by Repo.store.contacts.collectAsState()
val me = Repo.me
val verified = remember(ious) {
ious.values.mapNotNull { iou -> Verifier.verify(iou).stateOrNull?.let { iou to it } }
.sortedByDescending { it.first.head.timestamp }
}
// OwUs that are over live under Settings; this screen is what is live.
// Anything in your hands is held - including a promise of your own that
// is on its way somewhere, which says "not given yet" for itself. One
// somebody else holds and you wrote is a debt.
val open = verified.filterNot { it.second.status == Status.REDEEMED }
val held = open.filter { it.second.holder == me }
// OwUs you can do nothing about from here - what you owe, what is spent,
// and the promises you keep ready - are all under Settings.
val other = open.filterNot { it.second.holder == me || it.second.debtor == me }
val titleOther = stringResource(R.string.section_other)
Scaffold(
topBar = {
TopAppBar(
// The outward name, the same one on the launcher: this bar is
// the first thing anybody sees of the app.
title = { Text(BuildConfig.PUBLIC_NAME) },
actions = {
// No trade icon here any more. Trading is something you do
// with a person, not with the app, so it starts from the
// person - tap them under your own profile and their table
// opens. One fewer thing in the bar, and one fewer screen
// between wanting to trade and trading.
BluetoothAction()
IconButton(onClick = { nav.navigate("identity") }) {
Icon(Icons.Default.Person, contentDescription = stringResource(R.string.identity_title))
}
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings))
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.navigate("issue") }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_new))
}
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (open.isEmpty()) {
Text(
stringResource(R.string.list_empty),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.Center).padding(32.dp),
)
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
// What you hold needs no heading: it is what this screen is.
section(null, held, nav)
section(titleOther, other, nav)
item { Spacer(Modifier.size(80.dp)) } // room under the FAB
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.section(
title: String?,
rows: List<Pair<Iou, IouState>>,
nav: NavHostController,
) {
if (rows.isEmpty()) return
if (title != null) item { SectionHeader(title) }
items(rows.size) { i ->
val (iou, state) = rows[i]
IouRow(iou, state) { nav.navigate("iou/${iou.id}") }
}
}
@Composable
fun SectionHeader(text: String) {
Text(
text,
style = MaterialTheme.typography.subtitle2,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
/**
* "from Alice" when you hold it, "to Bob" when you owe it, and for an OwU
* sitting with the person who made it, what that means: not given yet, back
* with them, or spent.
*/
@Composable
fun counterpartyLine(state: IouState): String = when {
state.debtor == state.holder -> when {
state.status == Status.REDEEMED -> stringResource(R.string.closed_by_x, Repo.nameOf(state.debtor))
state.debtor == Repo.me -> stringResource(R.string.yours_to_give)
else -> stringResource(R.string.back_with_x, Repo.nameOf(state.debtor))
}
state.debtor == Repo.me -> stringResource(R.string.to_x, Repo.nameOf(state.holder))
state.holder == Repo.me -> stringResource(R.string.from_x, Repo.nameOf(state.debtor))
else -> "${Repo.nameOf(state.debtor)} → ${Repo.nameOf(state.holder)}"
}
@Composable
fun IouRow(iou: Iou, state: IouState, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
elevation = 2.dp,
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(iou.metadata.title, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
Text(
counterpartyLine(state),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
MetaGates(iou.metadata)
}
// No status here: every row on a list is the same status as the
// list it is on, so the label only ever repeated the screen.
}
}
}
@Composable
fun TemplateRow(template: Template, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
elevation = 2.dp,
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
template.metadata.title.ifBlank { stringResource(R.string.untitled) },
style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.template_ready),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
}
// How many are out there, so a preset shows what it has done.
val given = Repo.store.ious.collectAsState().value.values.count {
it.metadata == template.metadata && (it.chain.firstOrNull() as? Block.Issue)?.debtor == Repo.me && it.chain.size > 1
}
if (given > 0) {
Text(
pluralStringResource(R.plurals.template_given, given, given),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
}
}
}
@@ -0,0 +1,112 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouState
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
/**
* OwUs that are over. They are kept - a spent promise is the proof it was
* kept - but they are history, so they live here rather than among the OwUs
* that still mean something.
*/
@Composable
fun RedeemedScreen(nav: NavHostController) = IouListPage(
nav = nav,
title = stringResource(R.string.redeemed_title),
empty = stringResource(R.string.redeemed_empty),
) { _, state -> isSpent(state) }
/**
* What you owe: promises of yours that somebody else is holding. They are
* somebody else's to bring back, so they are not among the OwUs you can act
* on - but you should be able to look them in the eye.
*/
@Composable
fun OwedScreen(nav: NavHostController) = IouListPage(
nav = nav,
title = stringResource(R.string.owed_title),
empty = stringResource(R.string.owed_empty),
) { _, state -> owedByMe(state) }
/** A promise of mine that somebody else is holding. */
internal fun owedByMe(state: IouState): Boolean =
state.status != Status.REDEEMED && state.debtor == Repo.me && state.holder != Repo.me
/** A promise that has been kept, and is now only a record. */
internal fun isSpent(state: IouState): Boolean = state.status == Status.REDEEMED
/** A screen that is one filtered list of OwUs and nothing else. */
@Composable
private fun IouListPage(
nav: NavHostController,
title: String,
empty: String,
include: (Iou, IouState) -> Boolean,
) {
val ious by Repo.store.ious.collectAsState()
val rows = remember(ious, title) {
ious.values.mapNotNull { iou -> Verifier.verify(iou).stateOrNull?.let { iou to it } }
.filter { include(it.first, it.second) }
.sortedByDescending { it.first.head.timestamp }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (rows.isEmpty()) {
Text(
empty,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.Center).padding(32.dp),
)
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(rows.size) { i ->
val (iou, state) = rows[i]
IouRow(iou, state) { nav.navigate("iou/${iou.id}") }
}
}
}
}
}
@@ -0,0 +1,326 @@
package net.helcel.owu.activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import net.helcel.owu.R
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.helper.Locator
import net.helcel.owu.helper.toast
import net.helcel.owu.ledger.GeoLoc
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.store.Template
import net.helcel.owu.store.Repo
import java.util.UUID
/**
* Write a promise. Two jobs, differing in what is left behind.
*
* A **template** ([asTemplate]) is kept as written and signs nothing; an OwU is
* minted from it each time it goes on a table. [templateId] is null for a new
* one, else the one being edited.
*
* A **one-off** is signed there and then and nothing is filed: into your own
* hands from the home screen, onto the table if written at one.
*/
@Composable
fun IssueScreen(nav: NavHostController, templateId: String? = null, asTemplate: Boolean = false) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val template = remember(templateId) { templateId?.let { Repo.store.templates.value[it] } }
val id = remember { template?.id ?: UUID.randomUUID().toString() }
var title by remember { mutableStateOf(template?.metadata?.title ?: "") }
var description by remember { mutableStateOf(template?.metadata?.description ?: "") }
val geo = template?.metadata?.geoloc
var hasGeo by remember { mutableStateOf(geo != null) }
var geoLabel by remember { mutableStateOf(geo?.label ?: "") }
var lat by remember { mutableStateOf(geo?.latitude?.toString() ?: "") }
var lon by remember { mutableStateOf(geo?.longitude?.toString() ?: "") }
var radius by remember { mutableStateOf(geo?.radiusM?.toString() ?: "200") }
var locating by remember { mutableStateOf(false) }
// Backing out of a trade's new promise leaves nothing pending.
DisposableEffect(Unit) { onDispose { TradeIntent.pending = null } }
var notBefore by remember { mutableStateOf(template?.metadata?.notBefore) }
var notAfter by remember { mutableStateOf(template?.metadata?.notAfter) }
var hasWindow by remember { mutableStateOf(template?.metadata?.hasWindow == true) }
// The keyboard walks through the form: every field offers "next" and
// moves focus on, and the last one offers "done" and puts the keyboard
// away. Next, not Down: latitude and longitude sit side by side, and
// "down" from latitude skipped past longitude to the radius below.
val focus = LocalFocusManager.current
val onwards = KeyboardActions(
onNext = { focus.moveFocus(FocusDirection.Next) },
onDone = { focus.clearFocus() },
)
fun stepping(last: Boolean, type: KeyboardType = KeyboardType.Text) = KeyboardOptions(
keyboardType = type,
imeAction = if (last) ImeAction.Done else ImeAction.Next,
)
fun parsedGeo(): GeoLoc? {
if (!hasGeo) return null
val la = lat.replace(',', '.').toDoubleOrNull() ?: return null
val lo = lon.replace(',', '.').toDoubleOrNull() ?: return null
val r = radius.toIntOrNull() ?: return null
if (la !in -90.0..90.0 || lo !in -180.0..180.0 || r <= 0) return null
return GeoLoc.of(la, lo, r, geoLabel.ifBlank { null })
}
fun metadata() = Metadata(
title = title.trim(),
description = description.trim().ifBlank { null },
geoloc = parsedGeo(),
notBefore = notBefore.takeIf { hasWindow },
notAfter = notAfter.takeIf { hasWindow },
)
fun save() {
if (title.isBlank()) {
context.toast(context.getString(R.string.issue_need_title)); return
}
if (hasGeo && parsedGeo() == null) {
context.toast(context.getString(R.string.issue_bad_geo)); return
}
if (hasWindow && notBefore != null && notAfter != null && notBefore!! > notAfter!!) {
context.toast(context.getString(R.string.issue_bad_window)); return
}
val saved = metadata()
if (asTemplate) {
Repo.store.putTemplate(Template(id, saved, template?.createdAt ?: Ledger.now()))
} else {
// A one-off is signed now. At a table it is minted straight onto
// it; otherwise it is minted here, into my own hands, and shows on
// the home screen as mine to give.
val trading = TradeIntent.take()
if (trading != null) PeerManager.readySession(trading.peerKey)?.putNew(saved)
else Repo.store.put(Ledger.issue(Repo.signer, saved))
}
nav.up()
}
fun locate() {
locating = true
scope.launch {
val loc = Locator.current(context)
locating = false
if (loc == null) {
context.toast(context.getString(R.string.geo_unavailable))
} else {
lat = "%.6f".format(loc.latitude)
lon = "%.6f".format(loc.longitude)
}
}
}
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) locate() else context.toast(context.getString(R.string.geo_permission_denied))
}
Scaffold(
topBar = {
TopAppBar(
// The title says which of the two this is: a promise being
// made once, or one being kept for next time.
title = {
Text(
stringResource(
when {
!asTemplate -> R.string.action_new
template == null -> R.string.template_new
else -> R.string.edit_template
}
)
)
},
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (template != null) {
IconButton(onClick = { Repo.store.removeTemplate(template.id); nav.up() }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
}
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
OutlinedTextField(
value = title, onValueChange = { title = it },
label = { Text(stringResource(R.string.field_title)) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = stepping(last = false), keyboardActions = onwards,
)
// Room for the terms, or the occasion, or the joke. Signed with
// the rest, so neither side can rewrite it afterwards.
OutlinedTextField(
value = description, onValueChange = { description = it },
label = { Text(stringResource(R.string.field_description)) },
placeholder = { Text(stringResource(R.string.field_description_hint)) },
minLines = 2, maxLines = 4,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
keyboardOptions = stepping(last = false), keyboardActions = onwards,
)
// Only worth saying when it is going somewhere the moment it is written.
TradeIntent.pending?.let {
Text(
stringResource(R.string.issue_onto_table, Repo.nameOf(it.peerKey)),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 12.dp),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp).clickable { hasGeo = !hasGeo },
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.field_geoloc), style = MaterialTheme.typography.subtitle1)
Text(
stringResource(R.string.field_geoloc_desc),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Switch(checked = hasGeo, onCheckedChange = { hasGeo = it })
}
if (hasGeo) {
OutlinedTextField(
value = geoLabel, onValueChange = { geoLabel = it },
label = { Text(stringResource(R.string.field_geo_label)) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
keyboardOptions = stepping(last = false), keyboardActions = onwards,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedTextField(
value = lat,
onValueChange = { lat = it },
label = { Text(stringResource(R.string.field_lat)) },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = stepping(last = false, type = KeyboardType.Decimal),
keyboardActions = onwards,
)
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = lon,
onValueChange = { lon = it },
label = { Text(stringResource(R.string.field_lon)) },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = stepping(last = false, type = KeyboardType.Decimal),
keyboardActions = onwards,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = radius, onValueChange = { radius = it },
label = { Text(stringResource(R.string.field_radius)) },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = stepping(last = true, type = KeyboardType.Number), keyboardActions = onwards,
)
Spacer(Modifier.size(8.dp))
OutlinedButton(
enabled = !locating,
onClick = { if (Locator.hasPermission(context)) locate() else permission.launch(Locator.PERMISSION) },
) {
Text(stringResource(if (locating) R.string.geo_locating else R.string.geo_use_current))
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp).clickable { hasWindow = !hasWindow },
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.field_window), style = MaterialTheme.typography.subtitle1)
Text(
stringResource(R.string.field_window_desc),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Switch(checked = hasWindow, onCheckedChange = { hasWindow = it })
}
if (hasWindow) {
DateTimeField(
label = stringResource(R.string.field_not_before),
value = notBefore, onChange = { notBefore = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
DateTimeField(
label = stringResource(R.string.field_not_after),
value = notAfter, onChange = { notAfter = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
Button(onClick = { save() }, modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
Text(stringResource(if (TradeIntent.pending != null) R.string.action_save_and_offer else R.string.action_save))
}
}
}
}
@@ -0,0 +1,206 @@
package net.helcel.owu.activity
import android.os.Bundle
import android.app.Activity
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material.AppBarDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.LocalElevationOverlay
import androidx.compose.material.MaterialTheme
import androidx.compose.material.primarySurface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import net.helcel.owu.ble.BlePermissions
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.store.Repo
class MainScreen : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
actionBar?.hide()
// Draw behind the system bars: no colour named in a file can match a
// Material You bar chosen from the wallpaper. Both fully transparent,
// since the default scrims the navigation bar a shade darker and looks
// like a second mismatch; [SystemBars] gives the icons their contrast.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
setContent {
SysTheme {
// What a Material 2 app bar paints itself: primarySurface
// *plus* the dark theme's elevation overlay, which lifts a 4dp
// bar off the background. Miss the overlay and the strip above
// it is a near miss, which reads worse than a plain difference.
val bar = MaterialTheme.colors.primarySurface
val barPainted = LocalElevationOverlay.current
?.apply(bar, AppBarDefaults.TopAppBarElevation) ?: bar
SystemBars(status = barPainted, navigation = MaterialTheme.colors.background)
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
// Status bar in the top bar's colour, the rest in the
// content's. Inside both insets, or the system buttons sit
// on whatever is at the foot of a screen.
Spacer(
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.windowInsetsTopHeight(WindowInsets.statusBars)
.background(barPainted)
)
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Root()
}
}
}
}
}
// BLE only while resumed. Rotation is exempt so trades survive it.
override fun onPause() {
super.onPause()
if (!isChangingConfigurations) PeerManager.pause()
}
/** Bar icons legible against what is behind them. The colour is a Material
* You one chosen on the device, so it is measured, not assumed. */
@Composable
private fun SystemBars(status: Color, navigation: Color) {
val view = LocalView.current
val paleStatus = status.luminance() > 0.5f
val paleNavigation = navigation.luminance() > 0.5f
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = paleStatus
isAppearanceLightNavigationBars = paleNavigation
}
}
}
/** Opens the store and the Keystore key off the main thread, then shows the app. */
@Composable
fun Root() {
val context = LocalContext.current
val ready by Repo.ready.collectAsState()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { Repo.init(context.applicationContext) }
}
if (!ready) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colors.primary,
strokeWidth = 4.dp,
modifier = Modifier.size(50.dp),
)
}
} else {
// BLE on while the app is in use.
val permissions = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted -> if (granted.values.all { it }) PeerManager.resume(context) }
var askedFor by rememberSaveable { mutableStateOf(false) }
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
// Runs only once the store has opened, which on a cold start is
// after ON_RESUME has gone by, and an observer hears only what
// comes next. So ask where the lifecycle is, not just where it goes.
fun begin() {
// Always: keeps the icon current without permission.
PeerManager.resume(context)
if (!BlePermissions.granted(context) && !askedFor) {
askedFor = true
permissions.launch(BlePermissions.required().toTypedArray())
}
}
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) begin()
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) begin()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
// Hoisted out of the nav host: one of the prompts below navigates.
val nav = rememberNavController()
AppNavHost(nav)
// Being asked to pay is worth interrupting for, wherever you are.
RedeemPrompt()
// Somebody opening a table with you is worth interrupting for too.
TradePrompt(nav)
PendingAsk()
// And what came of it: a table can finish with neither side looking.
OutcomeBanner()
}
}
@Composable
fun AppNavHost(nav: NavHostController) {
NavHost(nav, startDestination = "main") {
composable("main") { IouListScreen(nav) }
// Two ways to write a promise: once, or as a template to reuse.
composable("issue") { IssueScreen(nav) }
composable("template") { IssueScreen(nav, asTemplate = true) }
composable("template/{id}") {
IssueScreen(nav, templateId = it.arguments?.getString("id"), asTemplate = true)
}
composable("iou/{id}") { IouDetailScreen(nav, it.arguments?.getString("id") ?: "") }
composable("identity") { IdentityScreen(nav) }
composable("peer/{beacon}") { PeerScreen(nav, it.arguments?.getString("beacon") ?: "") }
composable("settings") {
SettingsMainScreen(
onExit = { nav.up() },
onRedeemed = { nav.navigate("redeemed") },
onOwed = { nav.navigate("owed") },
onTemplates = { nav.navigate("templates") },
)
}
composable("redeemed") { RedeemedScreen(nav) }
composable("owed") { OwedScreen(nav) }
composable("templates") { TemplatesScreen(nav) }
}
}
}
@@ -0,0 +1,134 @@
package net.helcel.owu.activity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import net.helcel.owu.R
import net.helcel.owu.peer.Outcome
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.store.Repo
/**
* What just happened, said out loud. A table used to finish by quietly
* emptying itself, and a redeem answered from a prompt finished with no screen
* at all. This lives at the app root and speaks wherever you are.
*/
@Composable
fun OutcomeBanner() {
// Two states, not one: the card keeps saying what it said while it slides
// away, so the words outlive the showing.
var outcome by remember { mutableStateOf<Outcome?>(null) }
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
PeerManager.outcomes.collect {
outcome = it
visible = true
}
}
LaunchedEffect(outcome) {
if (outcome == null) return@LaunchedEffect
delay(SHOW_MS)
visible = false
}
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.BottomCenter) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
) {
outcome?.let { Banner(it) { visible = false } }
}
}
}
@Composable
private fun Banner(outcome: Outcome, onDismiss: () -> Unit) {
// A refusal is no failure, and must not look like a success either.
val no = outcome.declined != null
val paper = if (no) MaterialTheme.colors.surface else MaterialTheme.colors.primary
val ink = if (no) MaterialTheme.colors.onSurface else MaterialTheme.colors.onPrimary
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onDismiss),
elevation = 8.dp,
backgroundColor = paper,
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
if (no) Icons.Default.Close else Icons.Default.Check,
contentDescription = null,
tint = paper,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ink.copy(alpha = if (no) 0.5f else 1f))
.padding(4.dp),
)
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
headline(outcome),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Medium,
color = ink,
)
outcome.peer?.let {
Text(
stringResource(if (no) R.string.from_x else R.string.done_with, Repo.nameOf(it)),
style = MaterialTheme.typography.caption,
color = ink.copy(alpha = 0.7f),
)
}
}
}
}
}
/** The one sentence for it: redeeming first, since that is what ends a loop. */
@Composable
private fun headline(outcome: Outcome): String = when {
outcome.declined == PeerMessage.Asked.INVITE -> stringResource(R.string.done_declined_trade)
outcome.declined != null -> stringResource(R.string.done_declined_ask)
outcome.redeemed.isNotEmpty() -> stringResource(R.string.done_redeemed, bundleLine(outcome.redeemed))
outcome.gave.isNotEmpty() && outcome.got.isNotEmpty() ->
stringResource(R.string.done_traded, bundleLine(outcome.gave), bundleLine(outcome.got))
outcome.gave.isNotEmpty() -> stringResource(R.string.done_gave, bundleLine(outcome.gave))
else -> stringResource(R.string.done_got, bundleLine(outcome.got))
}
/** Long enough to read, short enough not to be in the way. */
private const val SHOW_MS = 4_000L
@@ -0,0 +1,129 @@
package net.helcel.owu.activity
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random
/**
* The app's face, drawn rather than stamped from the launcher icon so it can
* do the one thing an icon cannot: now and then the left eye closes and OwU
* becomes UwU. Both eyes are one shape ([eye]) at different points of the same
* animation. Tapping winks on demand; left alone it winks rarely.
*/
@Composable
fun OwuFace(modifier: Modifier = Modifier) {
val lid = remember { Animatable(0f) } // 0 = open (O), 1 = shut (U)
val scope = rememberCoroutineScope()
suspend fun wink() {
lid.animateTo(1f, tween(160))
delay(340)
lid.animateTo(0f, tween(240))
}
// Rarely, and never on a schedule anyone could set a watch by.
LaunchedEffect(Unit) {
while (true) {
delay(Random.nextLong(9_000, 24_000))
wink()
}
}
val face = MaterialTheme.colors.onPrimary
val disc = MaterialTheme.colors.primary
Canvas(
modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { scope.launch { wink() } }
.semantics { contentDescription = "OwU" },
) {
val unit = size.minDimension / 108f
fun p(v: Float) = v * unit
val width = p(5f)
// One colour, edge to edge, and the face on it - what the launcher
// icon is, minus the shape the launcher itself decides.
drawRect(disc)
scale(0.88f, pivot = Offset(p(54f), p(52f))) {
// Both eyes stand on the same baseline, y = 60, and reach y = 44.
eye(cx = p(32f), cy = p(52f), r = p(8f), shut = lid.value, color = face, width = width)
eye(cx = p(76f), cy = p(52f), r = p(8f), shut = 1f, color = face, width = width)
// The mouth stands on the same baseline as the eyes, with a gap
// either side so the three glyphs read as three.
drawPath(
path = Path().apply {
moveTo(p(47f), p(50f))
lineTo(p(50.5f), p(60f))
lineTo(p(54f), p(52f))
lineTo(p(57.5f), p(60f))
lineTo(p(61f), p(50f))
},
color = face,
style = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
}
}
/**
* One eye between open and shut, in two strokes: the U - bottom half-circle
* and two vertical stems - and the lid arcing over it. Closing raises the
* stems and lets the lid sink onto them, fading. The bottom never moves, the
* sides stay vertical, and [shut] = 1 is exactly the other eye's U: an eye
* shuts, it does not shrink or square off.
*/
private fun DrawScope.eye(cx: Float, cy: Float, r: Float, shut: Float, color: Color, width: Float) {
val stroke = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round)
// Up fast, so the U is there before the lid has finished leaving.
val stem = r * (1f - (1f - shut) * (1f - shut))
drawPath(
Path().apply {
moveTo(cx - r, cy - stem)
lineTo(cx - r, cy)
// Left, round the bottom, to the right.
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 180f, -180f, false)
lineTo(cx + r, cy - stem)
},
color,
style = stroke,
)
if (shut >= 1f) return
val bulge = r * (1f - shut)
drawPath(
Path().apply {
moveTo(cx - r, cy - stem)
// Handles four thirds of the bulge up: the usual approximation of
// a half circle, and a shallower arc as the lid comes down.
cubicTo(cx - r, cy - stem - bulge * 4f / 3f, cx + r, cy - stem - bulge * 4f / 3f, cx + r, cy - stem)
},
color,
alpha = (1f - shut) * (1f - shut),
style = stroke,
)
}
@@ -0,0 +1,316 @@
package net.helcel.owu.activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.helper.Gates
import net.helcel.owu.helper.Locator
import net.helcel.owu.helper.toast
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Contact
import net.helcel.owu.store.Repo
/** One person, once picked: the table between us. Opening this screen opens
* the connection and leaving it hangs up again. */
@Composable
fun PeerScreen(nav: NavHostController, beaconHex: String) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val st by PeerManager.state.collectAsState()
val session = st.peers[beaconHex]
if (session == null) {
// They walked away while we were looking at them.
LaunchedEffect(Unit) { nav.up() }
return
}
val ss by session.state.collectAsState()
val ious by Repo.store.ious.collectAsState()
val contacts by Repo.store.contacts.collectAsState()
val templates by Repo.store.templates.collectAsState()
val peer = ss.peer
val known = peer != null && contacts.any { it.publicKey == peer.key }
// Until the handshake finishes there is no identity to read a name from,
// and you arrived here by tapping somebody by name: say their name. The
// beacon is derived from their key, so this is the contact you picked,
// not a guess - it is only the *proof* that is still a second away.
val who = peerLabel(peer, known).ifBlank {
contacts.firstOrNull { Ble.beacon(it.publicKey).contentEquals(session.beacon) }?.name.orEmpty()
}
var connecting by remember { mutableStateOf(true) }
var linkError by remember { mutableStateOf(false) }
LaunchedEffect(beaconHex) {
connecting = true
linkError = !PeerManager.select(session.beacon)
connecting = false
}
// Once we are known to each other, tell them we are here: they get a
// prompt wherever they are, rather than having to guess that somebody is
// standing at a table waiting for them.
LaunchedEffect(beaconHex, ss.phase) {
if (ss.phase == PeerSession.Phase.READY) session.invite()
}
DisposableEffect(beaconHex) {
onDispose { PeerManager.release(session.beacon) }
}
var addName by remember { mutableStateOf<String?>(null) }
var pickMine by remember { mutableStateOf(false) }
// OwUs on the table we are not at the place of, so the table can say so
// in red. It is never a bar - the one who owes it decides.
var outOfPlace by remember { mutableStateOf(emptySet<String>()) }
val ready = ss.phase == PeerSession.Phase.READY && !connecting
// Verified, connected and nothing in the way: the state the tick stands for.
val settled = ready && !linkError
// Everything I could put down, minted ious for this table included: the
// picker shows what is already down as chosen rather than hiding it.
val mineHeld = remember(ious) {
ious.values.filter {
Verifier.verify(it).stateOrNull?.let { s -> s.holder == Repo.me && s.status == Status.ACTIVE } == true
}
}
// Putting ious back on the table with the person who owes them *is*
// redeeming: they go home and they close them.
val redeeming = ss.table.mine.isNotEmpty() &&
ss.table.mine.all { Verifier.verify(it).stateOrNull?.debtor == peer?.key }
// A redeem begun from the OwU itself: put it down as soon as this table
// can take it, so the holder does not have to find it again in the picker.
LaunchedEffect(ready, peer?.key) {
val key = peer?.key
if (ready && key != null) {
TradeIntent.takeOffer(key)?.let { offer ->
Repo.store.ious.value[offer.iouId]?.let { session.put(held = listOf(it)) }
}
}
}
val tableIds = (ss.table.mine + ss.table.theirs).joinToString(",") { it.id }
LaunchedEffect(tableIds) {
outOfPlace = (ss.table.mine + ss.table.theirs)
.filter { Gates.outOfPlace(context, it) }
.map { it.id }
.toSet()
}
val locationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) context.toast(context.getString(R.string.geo_permission_denied))
}
// --- dialogs -----------------------------------------------------------
addName?.let { name ->
AlertDialog(
onDismissRequest = { addName = null },
title = { Text(stringResource(R.string.contact_name_this)) },
text = {
Column {
OutlinedTextField(
value = name, onValueChange = { addName = it },
label = { Text(stringResource(R.string.name)) }, singleLine = true
)
Text(
stringResource(R.string.contact_name_hint),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = {
Repo.store.putContact(Contact(peer!!.key, name.trim()))
addName = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = { TextButton(onClick = { addName = null }) { Text(stringResource(R.string.cancel)) } },
)
}
if (pickMine) TablePicker(
templates = templates.values.sortedByDescending { it.createdAt },
held = mineHeld,
onTable = ss.table.mine,
onDismiss = { pickMine = false },
onWrite = {
pickMine = false
TradeIntent.pending = TradeIntent.Pending(peer!!.key)
nav.navigate("issue")
},
onPut = { ious, mint ->
pickMine = false
session.put(held = ious, mint = mint)
// Judging a place gate needs the permission; ask while there is
// still time, not at the moment of saying yes.
val placed = ious.any { it.metadata.geoloc != null } || mint.any { it.geoloc != null }
if (placed && !Locator.hasPermission(context)) locationPermission.launch(Locator.PERMISSION)
},
)
// --- screen ------------------------------------------------------------
Scaffold(
topBar = {
TopAppBar(
// The tick belongs beside whoever they are, which is the name
// when you have one and the key when you do not - and that is
// the title either way.
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(who.ifBlank { stringResource(R.string.peer_unknown) })
if (settled) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.peer_verified),
tint = LocalContentColor.current,
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// The key under the name, when the name is not the key already.
peerFingerprint(peer, who)?.let {
Text(
it, style = MaterialTheme.typography.caption,
fontFamily = FontFamily.Monospace, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
impersonating(peer)?.let {
Text(
it, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.error,
modifier = Modifier.padding(top = 4.dp)
)
}
// Only for somebody you have not named yet: this is the one place
// a peer met over the air can become a contact. Renaming one you
// already have belongs on the identity screen, where they live.
if (peer != null && !known) {
OutlinedButton(
onClick = { addName = peer.name },
modifier = Modifier.padding(top = 8.dp),
) {
Text(stringResource(R.string.contact_name_this))
}
}
// Only while something is still in the way; once it is settled the
// tick above says so and this row goes.
if (!settled) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
if (connecting || ss.phase == PeerSession.Phase.HANDSHAKE) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(8.dp))
}
Text(
when {
linkError -> stringResource(R.string.peer_no_link)
connecting -> stringResource(R.string.peer_connecting)
ss.phase == PeerSession.Phase.GONE -> stringResource(R.string.peer_gone)
else -> stringResource(R.string.peer_verifying)
},
style = MaterialTheme.typography.body2,
color = if (linkError || ss.phase == PeerSession.Phase.GONE) MaterialTheme.colors.error
else MaterialTheme.colors.primary,
)
}
}
Card(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), elevation = 2.dp) {
TradeTable(
table = ss.table,
theirName = who,
theirKey = peer?.key,
enabled = ready,
redeeming = redeeming,
outOfPlace = outOfPlace,
onPut = { pickMine = true },
onAccept = { session.accept() },
modifier = Modifier.padding(12.dp),
)
}
if (ss.waiting != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
Text(
stringResource(R.string.peer_waiting, ss.waiting ?: ""), style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), modifier = Modifier.weight(1f)
)
TextButton(onClick = { session.stopWaiting() }) { Text(stringResource(R.string.peer_stop_waiting)) }
}
}
if (ss.log.isNotEmpty()) {
Divider(modifier = Modifier.padding(vertical = 12.dp))
ss.log.takeLast(8).forEach {
Text(
it,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
}
@@ -0,0 +1,90 @@
package net.helcel.owu.activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import net.helcel.owu.R
import net.helcel.owu.ble.Ble
import net.helcel.owu.helper.toast
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Repo
/**
* Every ask to redeem, from the tap onward. The button writes down who is
* asked for what; finding them, connecting, the handshake and the sending all
* happen here at the app root, whatever screen the user went on to. The other
* phone need not have been found, or even be switched on.
*
* The ask keeps until it is *on their table*, not until sent: two phones can
* disagree about having been introduced, and putting that right costs the
* table it carried.
*/
@Composable
fun PendingAsk() {
val context = LocalContext.current
val waiting = TradeIntent.offer
// A "no" ends it, or the loop below re-asks every few seconds and the two
// phones refuse each other for as long as they share a room.
LaunchedEffect(Unit) {
PeerManager.outcomes.collect { outcome ->
if (outcome.declined == PeerMessage.Asked.TABLE && outcome.peer == TradeIntent.offer?.peerKey) {
TradeIntent.offer = null
}
}
}
// Keyed on *what* is asked, not the session: a peer already in the list
// never changes that object, so an effect keyed on it never runs.
LaunchedEffect(waiting?.peerKey, waiting?.iouId) {
val offer = waiting ?: return@LaunchedEffect
val hex = Ble.beaconHex(offer.peerKey)
var told = false
while (true) {
if (TradeIntent.offer != offer) return@LaunchedEffect
// Redeemed, given away or deleted: not ours to ask about any more.
val iou = Repo.store.ious.value[offer.iouId]
val state = iou?.let { Verifier.verify(it).stateOrNull }
if (state == null || state.holder != Repo.me || state.status != Status.ACTIVE) {
TradeIntent.offer = null
return@LaunchedEffect
}
val session = PeerManager.state.value.peers[hex]
if (session != null && !onTheTable(session, offer.iouId)) {
val ready = withTimeoutOrNull(ASK_WAIT_MS) {
PeerManager.select(session.beacon) &&
session.state.first { it.phase == PeerSession.Phase.READY }.let { true }
} == true
if (ready) {
session.offer(listOf(iou))
// Once per ask: the loop may send twice, the user asked once.
if (!told) {
context.toast(context.getString(R.string.redeem_asked, Repo.nameOf(offer.peerKey)))
told = true
}
}
}
delay(RETRY_MS)
}
}
}
/** Down on our side of their table, and accepted: the ask is with them. */
private fun onTheTable(session: PeerSession, iouId: String): Boolean {
val table = session.state.value.table
return table.mine.size == 1 && table.mine[0].id == iouId && table.accepted
}
/** Long enough for two phones to say hello over a fresh connection. */
private const val ASK_WAIT_MS = 12_000L
/** How often to look again. Short: usually the peer is right there and simply
* had not been heard from when the button was pressed. */
private const val RETRY_MS = 3_000L
@@ -0,0 +1,85 @@
package net.helcel.owu.activity
import androidx.compose.foundation.layout.Column
import androidx.compose.material.AlertDialog
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import net.helcel.owu.R
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Repo
/**
* Somebody is asking to redeem a promise of yours. It is the table like
* anything else, but with nothing to choose: they have put it down and said
* yes, so the only question is whether to accept. Asked here, wherever the
* debtor is, rather than on a trade screen holding one thing.
*/
@Composable
fun RedeemPrompt() {
val st by PeerManager.state.collectAsState()
// Asks turned down stay down until the table changes.
val refused = remember { mutableStateListOf<String>() }
var asking: Pair<PeerSession, List<Iou>>? = null
val live = mutableListOf<String>()
st.peers.forEach { (hex, session) ->
key(hex) {
val ss by session.state.collectAsState()
val theirs = ss.table.theirs
// Theirs alone, all of them mine, their yes given: a redemption.
val isAsk = theirs.isNotEmpty() && ss.table.mine.isEmpty() &&
ss.table.theyAccepted && !ss.table.accepted && !ss.table.conflict &&
theirs.all { Verifier.verify(it).stateOrNull?.debtor == Repo.me }
if (isAsk) {
live += ticket(hex, theirs)
if (ticket(hex, theirs) !in refused && asking == null) asking = session to theirs
}
}
}
// A refusal lasts as long as the ask it refused. Held for good, "Not now"
// would swallow every later attempt at the same promise.
LaunchedEffect(live.joinToString("|")) { refused.retainAll(live) }
asking?.let { (session, ious) ->
// The key checks the ious, so an ask stands with or without a name.
val who = session.state.value.peer?.let { Repo.nameOf(it.key) } ?: stringResource(R.string.peer_unknown)
AlertDialog(
onDismissRequest = { refused += ticket(net.helcel.owu.ble.Ble.hex(session.beacon), ious) },
title = { Text(stringResource(R.string.redeem_ask, who)) },
text = {
Column {
Text(bundleLine(ious), style = MaterialTheme.typography.h6)
ious.firstOrNull()?.let { MetaGates(it.metadata, homecoming = true) }
}
},
confirmButton = {
TextButton(onClick = { session.accept() }) { Text(stringResource(R.string.action_accept)) }
},
dismissButton = {
TextButton(onClick = {
refused += ticket(net.helcel.owu.ble.Ble.hex(session.beacon), ious)
// Say so: their side clears the ask with ours, so they get
// the Redeem button back instead of waiting on you.
session.decline(PeerMessage.Asked.TABLE)
}) { Text(stringResource(R.string.redeem_later)) }
},
)
}
}
/** What was refused: this peer asking for exactly these ious. */
private fun ticket(hex: String, ious: List<Iou>): String =
hex + ious.map { it.id }.sorted().joinToString(",")
@@ -0,0 +1,232 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import net.helcel.owu.R
import net.helcel.owu.activity.sub.AboutScreen
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
@Preview
@Composable
fun SettingsMainScreen(
onExit: () -> Unit = {},
onRedeemed: () -> Unit = {},
onOwed: () -> Unit = {},
onTemplates: () -> Unit = {},
) {
val nav: NavHostController = rememberNavController()
SysTheme {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_settings)) },
navigationIcon = {
IconButton(onClick = {
if (!nav.popBackStack())
onExit()
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
) { innerPadding ->
// One host, inside the scaffold. Building it here and again as a
// default argument left two of them: settings drawn twice, and
// only the inner one ever navigating.
Box(modifier = Modifier.padding(innerPadding)) {
NavHost(nav, startDestination = "settings") {
composable("settings") { SettingsScreen(nav, onRedeemed, onOwed, onTemplates) }
composable("about") { AboutScreen() }
}
}
}
}
}
/**
* Four kinds of thing in one screen - lists to open, a switch, a choice, and
* two actions - so each kind gets one shape and keeps it: rows that open
* something look like rows, and only the backup buttons look like buttons.
*/
@Composable
fun SettingsScreen(
navController: NavHostController,
onRedeemed: () -> Unit = {},
onOwed: () -> Unit = {},
onTemplates: () -> Unit = {},
) {
val context = LocalContext.current
// What each list holds, counted the same way the list itself filters.
val ious by Repo.store.ious.collectAsState()
val templates by Repo.store.templates.collectAsState()
val (owed, spent) = remember(ious) {
val states = ious.values.mapNotNull { Verifier.verify(it).stateOrNull }
states.count(::owedByMe) to states.count(::isSpent)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
) {
// OwUs you cannot act on from the home screen - what you keep ready,
// what you owe, and what is spent - are worth looking up.
Section(stringResource(R.string.pref_category_notes)) {
NavRow(stringResource(R.string.section_templates), templates.size, onTemplates)
NavRow(stringResource(R.string.owed_title), owed, onOwed)
NavRow(stringResource(R.string.redeemed_title), spent, onRedeemed)
}
Section(stringResource(R.string.key_theme)) {
Segmented(
listOf(
stringResource(R.string.system),
stringResource(R.string.light),
stringResource(R.string.dark),
),
ThemeChoice.current(context),
) { picked -> ThemeChoice.set(context, picked) }
}
Section(stringResource(R.string.pref_category_data)) {
BackupSection()
}
Spacer(Modifier.height(16.dp))
PreferenceButton(stringResource(R.string.about)) {
if (navController.currentDestination?.route != "about")
navController.navigate("about")
}
}
}
/** A heading in the app's own section style, and whatever belongs under it. */
@Composable
private fun Section(title: String, content: @Composable () -> Unit) {
SectionHeader(title)
content()
}
/** A row that opens a list: what it is, how much is in it, and a chevron. */
@Composable
private fun NavRow(title: String, count: Int, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 14.dp),
) {
Text(
title,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.weight(1f),
)
// An empty list says so when you open it; no need for a nought here.
if (count > 0) {
Text(
count.toString(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
}
/** One of a few, side by side: shorter than a stack of radio buttons. */
@Composable
private fun Segmented(options: List<String>, selected: String, onSelect: (String) -> Unit) {
val shape = RoundedCornerShape(8.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(shape)
.border(1.dp, MaterialTheme.colors.primary.copy(alpha = 0.4f), shape)
) {
options.forEach { option ->
val on = option == selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.height(44.dp)
.background(if (on) MaterialTheme.colors.primary else Color.Transparent)
.clickable { onSelect(option) },
) {
Text(
option,
style = MaterialTheme.typography.button,
color = if (on) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground,
)
}
}
}
}
/** The one thing here that is not a setting, and so is not a row. */
@Composable
fun PreferenceButton(text: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) { Text(text) }
}
@@ -0,0 +1,81 @@
package net.helcel.owu.activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.store.Repo
/**
* The promises you keep ready to make. Nothing here is owed to anybody: each
* is a form waiting to be signed, and putting one on a table mints an OwU
* from it.
*/
@Composable
fun TemplatesScreen(nav: NavHostController) {
val templates by Repo.store.templates.collectAsState()
val rows = templates.values.sortedByDescending { it.createdAt }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.section_templates)) },
navigationIcon = {
IconButton(onClick = { nav.up() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.navigate("template") }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.template_new))
}
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
if (rows.isEmpty()) {
Text(
stringResource(R.string.templates_empty),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.Center).padding(32.dp),
)
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(rows.size) { i ->
val template = rows[i]
TemplateRow(template) { nav.navigate("template/${template.id}") }
}
item { Spacer(Modifier.size(80.dp)) } // room under the FAB
}
}
}
}
@@ -0,0 +1,140 @@
package net.helcel.owu.activity
import android.app.WallpaperManager
import android.content.Context
import android.os.Build
import android.graphics.Color as AndroidColor
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
import net.helcel.owu.R
import net.helcel.owu.helper.defaultPreferences
/**
* Which theme the user picked. It is state as well as a preference, because
* picking one has to repaint the screen it was picked on, and a preference
* read during composition never comes back to say it has changed.
*/
object ThemeChoice {
private var chosen by mutableStateOf<String?>(null)
fun current(context: Context): String = chosen ?: stored(context)
fun set(context: Context, value: String) {
defaultPreferences(context).edit { putString(context.getString(R.string.key_theme), value) }
chosen = value
}
private fun stored(context: Context): String = defaultPreferences(context).getString(
context.getString(R.string.key_theme),
context.getString(R.string.system),
)!!
}
/**
* The wallpaper's accent, where the platform will not give us a palette.
* `getWallpaperColors` has been there since API 27 - below our own minimum -
* and asks no permission, while Material You only arrives at 31. Null if
* there is nothing to read.
*/
private fun wallpaperAccent(context: Context): Color? {
val argb = runCatching {
WallpaperManager.getInstance(context)
.getWallpaperColors(WallpaperManager.FLAG_SYSTEM)?.primaryColor?.toArgb()
}.getOrNull() ?: return null
return Color(argb)
}
/**
* The baseline scheme with its primary family re-hued to [seed].
*
* Not a tonal palette - that wants a library this app does without - so only
* the hue and some of the saturation are the wallpaper's. Brightness is forced
* into the band Material uses for the theme, which is what keeps text legible
* on it whatever the wallpaper happens to be.
*/
private fun ColorScheme.accented(seed: Color, dark: Boolean): ColorScheme {
val hsv = FloatArray(3)
AndroidColor.colorToHSV(seed.toArgb(), hsv)
fun of(saturation: Float, value: Float) =
Color(AndroidColor.HSVToColor(floatArrayOf(hsv[0], saturation.coerceIn(0f, 1f), value)))
val s = hsv[1]
return if (dark) copy(
primary = of(s.coerceIn(0.10f, 0.55f), 0.92f),
onPrimary = Color.Black,
primaryContainer = of(s.coerceIn(0.15f, 0.70f), 0.35f),
onPrimaryContainer = of(s * 0.3f, 0.95f),
secondary = of(s * 0.4f, 0.85f),
) else copy(
primary = of(s.coerceIn(0.25f, 0.90f), 0.55f),
onPrimary = Color.White,
primaryContainer = of(s * 0.25f, 0.96f),
onPrimaryContainer = of(s.coerceIn(0.25f, 0.90f), 0.25f),
secondary = of(s * 0.4f, 0.50f),
)
}
@Composable
fun SysTheme(
content: @Composable () -> Unit
) {
val context = LocalContext.current
val themeKey = ThemeChoice.current(context)
val darkTheme = when (themeKey) {
stringResource(R.string.system) -> isSystemInDarkTheme()
stringResource(R.string.light) -> false
stringResource(R.string.dark) -> true
else -> isSystemInDarkTheme()
}
val colorScheme = when {
// Material You: the platform hands us a whole palette from the wallpaper.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
// Below that there is no palette to ask for, but the wallpaper's own
// accent is still readable, so the app is at least the right colour.
else -> {
val baseline = if (darkTheme) darkColorScheme() else lightColorScheme()
wallpaperAccent(context)?.let { baseline.accented(it, darkTheme) } ?: baseline
}
}
val m2colors = Colors(
primary = colorScheme.primary,
primaryVariant = colorScheme.primaryContainer,
secondary = colorScheme.secondary,
background = colorScheme.background,
surface = colorScheme.surface,
onPrimary = colorScheme.onPrimary,
onSecondary = colorScheme.onSecondary,
onBackground = colorScheme.onBackground,
onSurface = colorScheme.onSurface,
secondaryVariant = colorScheme.secondary,
error = colorScheme.error,
onError = colorScheme.onError,
isLight = !darkTheme,
)
// Both, because the app mixes the two: most of it is Material 2, while the
// dividers are Material 3 and would otherwise fall back to the default
// light scheme.
androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
MaterialTheme(
colors = m2colors,
content = content
)
}
}
@@ -0,0 +1,26 @@
package net.helcel.owu.activity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** What a screen was opened for, on the way to somebody's table. Gone once claimed. */
object TradeIntent {
/** The new promise being written belongs on this peer's table. */
data class Pending(val peerKey: String)
// Compose state, not plain fields: a plain var gives screens no reason to look again.
var pending: Pending? by mutableStateOf(null)
fun take(): Pending? = pending.also { pending = null }
/** An OwU to put down once the table with [peerKey] opens: how a redeem
* begun from the OwU finds its table without hunting in the picker. */
data class Offer(val peerKey: String, val iouId: String)
var offer: Offer? by mutableStateOf(null)
/** Claims the waiting offer, if it is for this peer. */
fun takeOffer(peerKey: String): Offer? =
offer?.takeIf { it.peerKey == peerKey }?.also { offer = null }
}
@@ -0,0 +1,65 @@
package net.helcel.owu.activity
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import net.helcel.owu.R
import net.helcel.owu.peer.PeerManager
import net.helcel.owu.peer.PeerMessage
import net.helcel.owu.peer.PeerSession
import net.helcel.owu.store.Repo
/**
* Somebody has opened a table with you; without this they would have to be on
* the right screen at the right moment to find out. Same shape as
* [RedeemPrompt]. Only from people you have named: trading is contacts-only,
* and a dialog any passer-by could raise on your phone is a nuisance.
*/
@Composable
fun TradePrompt(nav: NavHostController) {
val st by PeerManager.state.collectAsState()
val contacts by Repo.store.contacts.collectAsState()
val route = nav.currentBackStackEntryAsState().value?.destination?.route
var asking: Pair<String, PeerSession>? = null
st.peers.forEach { (hex, session) ->
key(hex) {
val ss by session.state.collectAsState()
val known = ss.peer?.key?.let { k -> contacts.any { it.publicKey == k } } == true
// Already at their table: being there is the answer.
val attending = route == "peer/{beacon}" &&
nav.currentBackStackEntry?.arguments?.getString("beacon") == hex
if (ss.invited && attending) LaunchedEffect(hex) { session.inviteAnswered() }
else if (ss.invited && known && asking == null) asking = hex to session
}
}
asking?.let { (hex, session) ->
val who = session.state.value.peer?.let { Repo.nameOf(it.key) } ?: stringResource(R.string.peer_unknown)
AlertDialog(
// Dismissing is not a refusal to report; only "Not now" tells them.
onDismissRequest = { session.inviteAnswered() },
title = { Text(stringResource(R.string.trade_ask, who)) },
confirmButton = {
TextButton(onClick = {
session.inviteAnswered()
nav.navigate("peer/$hex")
}) { Text(stringResource(R.string.trade_accept)) }
},
dismissButton = {
TextButton(onClick = {
session.inviteAnswered()
session.decline(PeerMessage.Asked.INVITE)
}) { Text(stringResource(R.string.redeem_later)) }
},
)
}
}
@@ -0,0 +1,602 @@
package net.helcel.owu.activity
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.text.format.DateFormat
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentColor
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import androidx.navigation.NavHostController
import net.helcel.owu.R
import net.helcel.owu.crypto.Keys
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.helper.formatTime
import net.helcel.owu.peer.PeerEngine
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.TimeGate
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.Repo
import net.helcel.owu.store.Template
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
// Pieces shared by more than one screen.
@Composable
fun statusLabel(status: Status): String = when (status) {
Status.ACTIVE -> stringResource(R.string.status_active)
Status.REDEEMED -> stringResource(R.string.status_redeemed)
}
@Composable
fun peerLabel(peer: IdentityCard?, known: Boolean): String = when {
peer == null -> ""
known -> Repo.nameOf(peer.key)
else -> peer.name.ifBlank { Keys.fingerprint(peer.key) }
}
/** Set when a peer calls itself a name you know somebody else by. Names are
* self-asserted, keys are not: the one place a stranger can try to pass. */
@Composable
fun impersonating(peer: IdentityCard?): String? {
if (peer == null || peer.name.isBlank()) return null
val contacts by Repo.store.contacts.collectAsState()
val clash = contacts.firstOrNull { it.name.equals(peer.name.trim(), ignoreCase = true) && it.publicKey != peer.key }
return clash?.let { stringResource(R.string.peer_name_clash, it.name) }
}
/** A tick beside a name whose key we keep: that person's promise, not merely
* a valid signature by a stranger. A missing tick is the whole message. */
@Composable
fun KnownMark(publicKey: String, modifier: Modifier = Modifier) {
if (!Repo.knows(publicKey)) return
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.in_your_contacts),
tint = MaterialTheme.colors.primary,
modifier = modifier.padding(start = 4.dp).size(14.dp),
)
}
/** The key under a name, unless the name *is* the key already. */
@Composable
fun peerFingerprint(peer: IdentityCard?, label: String): String? =
peer?.let { Keys.fingerprint(it.key) }?.takeIf { it != label }
/**
* What to put on the table: nothing, promises minted from templates, or OwUs
* you hold - any number, in one bundle.
*
* A sheet, not a dialog: the list has no known length, and as a dialog it grew
* to fill the screen badly. Writing a new promise leads, being the one entry
* that is an action; templates follow, the common case being the same beer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TablePicker(
templates: List<Template>,
held: List<Iou>,
/** What is already on my side, so the sheet opens on the bundle as it stands. */
onTable: List<Iou>,
onDismiss: () -> Unit,
onWrite: () -> Unit,
/** The bundle as chosen: ious I hold, and one mint per template copy. */
onPut: (List<Iou>, List<Metadata>) -> Unit,
) {
// A minted beer on the table counts as one beer of its template, so
// reopening the sheet shows what is down rather than starting empty.
val down = remember(onTable) { onTable.map { it.id }.toSet() }
val counts = remember(onTable) { mutableStateMapOf<String, Int>() }
val chosen = remember(onTable) { mutableStateListOf<String>().apply { addAll(down) } }
val total = counts.values.sum() + chosen.size
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
) {
// M3 sheet, M2 content, separate LocalContentColors: M3's contentColor
// never reaches an M2 Text, which defaults to black and vanishes on a
// dark sheet. Hand M2 the same colour.
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colors.onSurface) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Text(
stringResource(R.string.table_pick_mine),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 4.dp),
)
// Weighted, not filled: content-sized while it fits, scrolling
// once it does not, so the buttons stay in reach.
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
PickerRow(
title = stringResource(R.string.table_new_promise),
icon = Icons.Outlined.Edit,
tint = MaterialTheme.colors.primary,
onClick = onWrite,
)
if (templates.isNotEmpty()) {
PickerHeading(stringResource(R.string.table_from_presets))
templates.forEach { t ->
CountRow(
title = t.metadata.title.ifBlank { stringResource(R.string.untitled) },
count = counts[t.id] ?: 0,
onChange = { n -> if (n <= 0) counts.remove(t.id) else counts[t.id] = n },
)
}
}
if (held.isNotEmpty()) {
PickerHeading(stringResource(R.string.table_from_held))
held.forEach { n ->
PickRow(
title = n.metadata.title,
subtitle = Verifier.verify(n).stateOrNull
?.let { stringResource(R.string.from_x, Repo.nameOf(it.debtor)) },
picked = n.id in chosen,
onToggle = { if (n.id in chosen) chosen.remove(n.id) else chosen.add(n.id) },
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val ious = held.filter { it.id in chosen }
val mint = templates.flatMap { t -> List(counts[t.id] ?: 0) { t.metadata } }
onPut(ious, mint)
},
) {
// Nothing chosen is a real choice: it takes my side off.
Text(
if (total == 0) stringResource(R.string.table_take_off)
else pluralStringResource(R.plurals.table_put_n, total, total)
)
}
}
}
}
}
}
/** A template, and how many copies of it go on the table. */
@Composable
private fun CountRow(title: String, count: Int, onChange: (Int) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 4.dp),
) {
Icon(
Icons.Outlined.ContentCopy, contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(16.dp))
Text(
title, style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.weight(1f).clickable { onChange(count + 1) },
)
IconButton(enabled = count > 0, onClick = { onChange(count - 1) }) {
Icon(
Icons.Default.Remove, contentDescription = stringResource(R.string.fewer),
tint = if (count > 0) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.3f),
)
}
Text(
count.toString(),
style = MaterialTheme.typography.body1,
color = if (count > 0) MaterialTheme.colors.onSurface
else MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.width(24.dp),
textAlign = TextAlign.Center,
)
IconButton(onClick = { onChange(count + 1) }) {
Icon(
Icons.Default.Add, contentDescription = stringResource(R.string.more),
tint = MaterialTheme.colors.primary
)
}
}
}
/** An OwU you hold: in the bundle, or not. There is only ever one of it. */
@Composable
private fun PickRow(title: String, subtitle: String?, picked: Boolean, onToggle: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.clickable(onClick = onToggle)
.padding(vertical = 8.dp),
) {
Icon(
if (picked) Icons.Default.CheckCircle else Icons.AutoMirrored.Filled.Send,
contentDescription = null,
tint = if (picked) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(16.dp))
Column {
Text(title, style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onSurface)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
}
}
}
/** A row that does one thing when tapped. */
@Composable
private fun PickerRow(
title: String,
icon: ImageVector,
tint: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(16.dp))
Text(title, style = MaterialTheme.typography.body1, color = tint)
}
}
@Composable
private fun PickerHeading(text: String) {
Text(
text,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
/** The table between two people: what each put down, and who said yes.
* Tapping my side opens the picker; the button is my yes. */
@Composable
fun TradeTable(
table: PeerEngine.Table,
theirName: String,
theirKey: String?,
enabled: Boolean,
onPut: () -> Unit,
onAccept: () -> Unit,
modifier: Modifier = Modifier,
/** My side is a promise of theirs: saying yes hands it home and closes it. */
redeeming: Boolean = false,
/** Ids of ious on the table whose place we are not at, to be shown in red. */
outOfPlace: Set<String> = emptySet(),
) {
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(R.string.table_title),
style = MaterialTheme.typography.subtitle2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.weight(1f),
)
TextButton(enabled = enabled, onClick = onPut) {
Text(stringResource(if (table.mine.isEmpty()) R.string.table_put else R.string.table_change))
}
}
TableSide(
who = stringResource(R.string.table_you),
ious = table.mine,
accepted = table.accepted,
owner = Repo.me,
recipient = theirKey,
outOfPlace = outOfPlace,
modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { onPut() },
)
Divider()
TableSide(
who = theirName,
ious = table.theirs,
accepted = table.theyAccepted,
owner = theirKey,
recipient = Repo.me,
outOfPlace = outOfPlace,
modifier = Modifier.fillMaxWidth(),
)
if (table.conflict) {
Text(
stringResource(R.string.table_conflict),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(top = 4.dp),
)
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Button(enabled = enabled && !table.empty && !table.accepted && !table.conflict, onClick = onAccept) {
Text(stringResource(if (redeeming) R.string.action_redeem else R.string.action_confirm))
}
}
if (table.accepted && !table.theyAccepted) {
Text(
stringResource(R.string.table_waiting_for, theirName), style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
private fun TableSide(
who: String,
ious: List<Iou>,
accepted: Boolean,
/** Who put this side down: their own promise, or one they pass on. */
owner: String?,
/** Who this side is going to, which is how an OwU coming home is known. */
recipient: String?,
outOfPlace: Set<String>,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.padding(vertical = 8.dp)) {
Text(
who, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.width(72.dp)
)
Column(modifier = Modifier.weight(1f)) {
if (ious.isEmpty()) {
Text(
stringResource(R.string.table_nothing),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
)
}
// Five of the same promise is one line saying five, not five lines.
ious.groupBy { it.metadata }.forEach { (metadata, same) ->
val first = same.first()
Text(
if (same.size > 1) "${same.size} × ${metadata.title}" else metadata.title,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
)
// Who has to make good on it: the thing worth knowing.
Verifier.verify(first).stateOrNull?.let { state ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
when (state.debtor) {
// Whoever put it down owes it themselves, which
// on my own side is me.
owner -> stringResource(
if (owner == Repo.me) R.string.own_promise_yours else R.string.own_promise
)
Repo.me -> stringResource(R.string.own_promise_mine)
else -> stringResource(R.string.owed_by_x, Repo.nameOf(state.debtor))
},
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
if (state.debtor != Repo.me) KnownMark(state.debtor)
}
// When and where: what the one who owes it is being asked
// about. Going home to its debtor is a redemption, so the
// window counts.
MetaGates(
metadata = metadata,
homecoming = state.debtor == recipient,
outOfPlace = same.any { it.id in outOfPlace },
)
}
}
}
if (accepted) Icon(
Icons.Default.Check, contentDescription = stringResource(R.string.action_accept),
tint = MaterialTheme.colors.primary
)
}
}
/** A QR code drawn straight onto a canvas; always black on white so any scanner reads it. */
@Composable
fun QrCode(text: String, modifier: Modifier = Modifier) {
val matrix = remember(text) {
QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, mapOf(EncodeHintType.MARGIN to 0))
}
Canvas(modifier.background(Color.White).padding(12.dp)) {
val cell = minOf(size.width / matrix.width, size.height / matrix.height)
val ox = (size.width - cell * matrix.width) / 2
val oy = (size.height - cell * matrix.height) / 2
for (y in 0 until matrix.height) for (x in 0 until matrix.width) {
if (matrix[x, y]) drawRect(
Color.Black,
Offset(ox + x * cell, oy + y * cell),
Size(cell + 0.5f, cell + 0.5f)
)
}
}
}
/**
* An optional instant, picked with the platform's date and time dialogs.
* Read-only field, a tap opens the pickers, the trailing button clears it.
*/
@Composable
fun DateTimeField(
label: String,
value: Long?,
onChange: (Long?) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
fun pick() {
val zone = ZoneId.systemDefault()
val start = value?.let { Instant.ofEpochSecond(it).atZone(zone) }
?: ZonedDateTime.now(zone).plusHours(1).withMinute(0)
DatePickerDialog(context, { _, y, m, d ->
TimePickerDialog(context, { _, h, min ->
onChange(ZonedDateTime.of(y, m + 1, d, h, min, 0, 0, zone).toEpochSecond())
}, start.hour, start.minute, DateFormat.is24HourFormat(context)).show()
}, start.year, start.monthValue - 1, start.dayOfMonth).show()
}
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
OutlinedTextField(
value = value?.let { formatTime(it) } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
)
// The field swallows taps; a transparent layer over it opens the pickers.
Box(Modifier.matchParentSize().clickable { pick() })
}
IconButton(onClick = { onChange(null) }, enabled = value != null) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
}
/**
* The strings an OwU carries about when and where it may be redeemed, under
* whatever it is attached to. They are never a bar - a late OwU can still be
* handed back - so they are here to be read, by the holder deciding to ask
* and by the one who wrote it deciding to say yes.
*/
@Composable
fun MetaGates(
metadata: Metadata,
/** This OwU is going home, so a window that is merely not open yet counts against it. */
homecoming: Boolean = false,
/** We are not where it says, as far as this phone can tell. */
outOfPlace: Boolean = false,
) {
if (metadata.hasWindow) {
val gate = metadata.timeGate()
// Expired is red wherever it is shown; too early only matters when
// somebody is trying to redeem it now.
val wrong = gate is TimeGate.Expired || (homecoming && gate is TimeGate.NotYet)
GateLine(
icon = Icons.Default.Schedule,
text = when (gate) {
is TimeGate.NotYet -> stringResource(R.string.window_from, formatTime(gate.opensAt))
is TimeGate.Expired -> stringResource(R.string.window_expired)
TimeGate.Open -> metadata.notAfter?.let { stringResource(R.string.window_until, formatTime(it)) } ?: ""
},
wrong = wrong,
)
}
metadata.geoloc?.let { geo ->
GateLine(
icon = Icons.Default.Place,
text = geo.label ?: "%.4f, %.4f".format(geo.latitude, geo.longitude),
wrong = outOfPlace,
)
}
}
@Composable
private fun GateLine(icon: ImageVector, text: String, wrong: Boolean) {
val color = if (wrong) MaterialTheme.colors.error else MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = color)
Spacer(Modifier.width(2.dp))
Text(text, style = MaterialTheme.typography.caption, color = color)
}
}
/**
* Go back, unless there is nothing to go back to. `popBackStack()` on the last
* entry leaves the NavHost with nothing to draw - a blank screen - which is
* what two quick taps on a back arrow, or one racing the system gesture, do.
*/
fun NavHostController.up() {
if (previousBackStackEntry != null) popBackStack()
}
/**
* A bundle in one line - "2 × 1 Beer, 1 Coffee" - rather than the same title
* over and over.
*/
fun bundleLine(ious: List<Iou>): String =
ious.groupingBy { it.metadata.title }.eachCount().entries
.joinToString(", ") { (title, n) -> if (n > 1) "$n × $title" else title }
@@ -0,0 +1,78 @@
package net.helcel.owu.activity.sub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.owu.BuildConfig
import net.helcel.owu.activity.OwuFace
import net.helcel.owu.R
@Preview
@Composable
fun AboutScreen(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(top = 20.dp).background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Masked like a launcher icon, because that is what it is a preview of.
OwuFace(modifier = Modifier.size(200.dp).clip(CircleShape))
Text(
text = BuildConfig.PUBLIC_NAME,
fontSize = 30.sp,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
)
Text(
text = BuildConfig.VERSION_NAME,
fontSize = 25.sp,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
)
Text(
text = stringResource(R.string.app_is_foss),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
)
val uriHandler = LocalUriHandler.current
val uri = stringResource(R.string.app_repo_uri)
Text(
text = stringResource(id = R.string.app_repo, uri),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier
.clickable {
uriHandler.openUri(uri)
}
.padding(vertical = 15.dp, horizontal = 10.dp)
)
}
}
@@ -0,0 +1,55 @@
package net.helcel.owu.ble
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import net.helcel.owu.crypto.Hash
import java.util.Base64
object Ble {
/**
* Manufacturer id our packets ride under. 0xFFFF is the id the SIG
* reserves for internal use and testing; our service UUID is what keeps
* other testers' packets out.
*/
const val MANUFACTURER = 0xFFFF
/** Eight bytes of a key's SHA-256: what a device is known as on the air. */
fun beacon(publicKey: String): ByteArray =
Hash.sha256(Base64.getDecoder().decode(publicKey)).copyOf(8)
fun beaconHex(publicKey: String): String = hex(beacon(publicKey))
/** A beacon as text, for keying maps by device. */
fun hex(beacon: ByteArray): String = Hash.hex(beacon)
}
object BlePermissions {
/** Runtime permissions Bluetooth needs on this OS version. */
fun required(): List<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
) else listOf(Manifest.permission.ACCESS_FINE_LOCATION)
fun granted(context: Context): Boolean = required().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
fun adapter(context: Context): BluetoothAdapter? =
context.getSystemService(BluetoothManager::class.java)?.adapter
}
/** What the link hands up: somebody is there, or somebody said something. */
sealed class Heard {
/** A device's advertisement, and how strongly it came in. */
class Presence(val beacon: ByteArray, val rssi: Int) : Heard()
/** One whole message, and the beacon (or its leading bytes) it came from. */
class Message(val from: ByteArray, val bytes: ByteArray) : Heard()
}
@@ -0,0 +1,60 @@
package net.helcel.owu.ble
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
/**
* Messages over a link that delivers bytes in order, in any sizes. A frame is
* `length (4) + sender beacon (8) + payload`, the length covering both.
*
* The beacon rides every frame: a connection says which *device* is talking,
* not which identity, and the side that accepted it may never have heard that
* device advertise.
*/
class Frames(private val limit: Int = MAX_MESSAGE) {
private val buffer = ByteArrayOutputStream()
/** One message, ready to be written in whatever chunks the link takes. */
fun frame(from: ByteArray, payload: ByteArray): ByteArray =
ByteBuffer.allocate(4 + BEACON + payload.size)
.putInt(BEACON + payload.size)
.put(from, 0, BEACON)
.put(payload)
.array()
/** Feeds what arrived, returning whatever is now whole as (beacon, payload).
* A frame longer than [limit] is not ours, so the stream is dropped. */
fun feed(bytes: ByteArray): List<Pair<ByteArray, ByteArray>> {
buffer.write(bytes)
val out = mutableListOf<Pair<ByteArray, ByteArray>>()
var have = buffer.toByteArray()
while (have.size >= 4) {
val length = ByteBuffer.wrap(have, 0, 4).int
if (length < BEACON || length > limit) {
buffer.reset()
return out
}
if (have.size < 4 + length) break
out += have.copyOfRange(4, 4 + BEACON) to have.copyOfRange(4 + BEACON, 4 + length)
have = have.copyOfRange(4 + length, have.size)
}
buffer.reset()
buffer.write(have)
return out
}
/** Forgets a half-received frame, as when a connection drops. */
fun clear() = buffer.reset()
/** Cuts [frame] into pieces of at most [size] bytes, as one link write each. */
fun chunks(frame: ByteArray, size: Int): List<ByteArray> {
require(size > 0) { "chunk size" }
return (frame.indices step size).map { frame.copyOfRange(it, minOf(it + size, frame.size)) }
}
companion object {
const val BEACON = 8
/** Nothing this app sends comes near this; anything longer is a broken stream. */
const val MAX_MESSAGE = 1 shl 18
}
}
@@ -0,0 +1,446 @@
package net.helcel.owu.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothGattServerCallback
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* An ordinary BLE connection. Every device advertises a connectable packet and
* scans for the same; a connection opens only when there is something to say.
* [Frames] framing, MTU-sized: the dialler writes the characteristic, the
* answerer notifies on it, and both run a server so either can dial.
*
* A second carrier once put messages into Bluetooth 5 extended advertisements.
* Dropped: two phones both reporting extended support could not hear each other.
*/
@SuppressLint("MissingPermission") // the manager checks BlePermissions before start()
class Link(
/** The application context: a GATT server outlives any one screen. */
private val context: Context,
private val adapter: BluetoothAdapter,
private val me: ByteArray,
/** Advertise/scan failed after start(). Binder thread. */
private val onBroken: (String) -> Unit = {},
) {
private val _presence = MutableSharedFlow<Heard.Presence>(extraBufferCapacity = 64)
/** Devices heard advertising, and what they can do. */
val presence: SharedFlow<Heard.Presence> = _presence
private val _messages = MutableSharedFlow<Heard.Message>(extraBufferCapacity = 64)
val messages: SharedFlow<Heard.Message> = _messages
/** One open connection, either one we made or one we accepted. */
private inner class Channel(
/** Empty until their first frame names them, on a connection we accepted. */
var beacon: ByteArray,
val device: BluetoothDevice,
/** Set when we dialled; null when they did. */
val gatt: BluetoothGatt?,
) {
val frames = Frames()
val write = Mutex()
var mtu: Int = DEFAULT_MTU
/** Completes when the connection is usable both ways. */
val ready = CompletableDeferred<Boolean>()
/** Completes when the last write was acknowledged. */
var sent: CompletableDeferred<Boolean>? = null
}
private val channels = ConcurrentHashMap<String, Channel>() // by device address
private val addresses = ConcurrentHashMap<String, String>() // beacon hex -> device address
private var server: BluetoothGattServer? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiser: android.bluetooth.le.BluetoothLeAdvertiser? = null
private var scanner: android.bluetooth.le.BluetoothLeScanner? = null
fun start(): String? {
val manager = context.getSystemService(BluetoothManager::class.java) ?: return "No Bluetooth service"
val chr = BluetoothGattCharacteristic(
DATA,
BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_WRITE,
).apply {
addDescriptor(
BluetoothGattDescriptor(
CCCD,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE,
)
)
}
val service = BluetoothGattService(SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY).apply {
addCharacteristic(chr)
}
val gattServer = manager.openGattServer(context, serverCallback) ?: return "Cannot open a GATT server"
gattServer.addService(service)
server = gattServer
characteristic = chr
val adv = adapter.bluetoothLeAdvertiser ?: return "This device cannot advertise"
advertiser = adv
// Connectable, legacy, 31 bytes: the service to be found by and our
// beacon to be known by.
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(false)
.addServiceUuid(ParcelUuid(SERVICE))
.addManufacturerData(Ble.MANUFACTURER, me)
.build()
runCatching { adv.startAdvertising(settings, data, advertiseCallback) }
.onFailure { return "Could not advertise: ${it.message}" }
val scan = adapter.bluetoothLeScanner ?: return "This device cannot scan"
scanner = scan
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE)).build()
val scanSettings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
runCatching { scan.startScan(listOf(filter), scanSettings, scanCallback) }
.onFailure { return "Could not scan: ${it.message}" }
return null
}
fun stop() {
runCatching { scanner?.stopScan(scanCallback) }
runCatching { advertiser?.stopAdvertising(advertiseCallback) }
channels.values.toList().forEach { drop(it) }
runCatching { server?.close() }
server = null
advertiser = null
scanner = null
addresses.clear()
}
/**
* Opens a connection to [beacon], or returns true if one is already up.
* Only the side that calls this dials; the other simply accepts.
*/
suspend fun connect(beacon: ByteArray): Boolean {
channels.values.firstOrNull { it.beacon.contentEquals(beacon) }?.let { open ->
return withTimeoutOrNull(CONNECT_MS) { open.ready.await() } ?: false
}
val address = addresses[Ble.hex(beacon)] ?: return false
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
val gatt = device.connectGatt(context, false, clientCallback, BluetoothDevice.TRANSPORT_LE) ?: return false
val channel = Channel(beacon, device, gatt)
channels[address] = channel
val ok = withTimeoutOrNull(CONNECT_MS) { channel.ready.await() } ?: false
if (!ok) drop(channel)
return ok
}
/** Hangs up on [beacon]; harmless if there is nothing to hang up on. */
fun disconnect(beacon: ByteArray) {
channels.values.filter { it.beacon.contentEquals(beacon) }.forEach { drop(it) }
}
private fun drop(channel: Channel) {
channels.remove(channel.device.address)
channel.frames.clear()
if (!channel.ready.isCompleted) channel.ready.complete(false)
channel.sent?.takeIf { !it.isCompleted }?.complete(false)
if (channel.gatt != null) {
runCatching { channel.gatt.disconnect() }
runCatching { channel.gatt.close() }
} else {
runCatching { server?.cancelConnection(channel.device) }
}
}
/** Sends one message to [to]; true once the link took it. With [dial],
* connects first if there is none - what a person's action deserves and
* a HELLO said into the dark does not. */
suspend fun send(to: ByteArray, message: ByteArray, dial: Boolean = false): Boolean {
val channel = channelTo(to) ?: (if (dial && connect(to)) channelTo(to) else null)
if (channel == null) {
Log.w(
TAG,
"send ${message.size}B to ${Ble.hex(to)}: no channel, have ${channels.values.map { Ble.hex(it.beacon) }}"
)
return false
}
if (!channel.ready.isCompleted && withTimeoutOrNull(CONNECT_MS) { channel.ready.await() } != true) {
Log.w(TAG, "send ${message.size}B to ${Ble.hex(to)}: channel never became ready")
drop(channel)
return false
}
val frame = channel.frames.frame(me, message)
// One write is an attribute value, which is 512 bytes however large
// the MTU says a packet may be.
val room = minOf(channel.mtu - ATT_OVERHEAD, MAX_ATTRIBUTE)
val kind = if (channel.gatt != null) "write" else "notify"
return channel.write.withLock {
var n = 0
for (chunk in channel.frames.chunks(frame, room)) {
n++
if (!writeChunk(channel, chunk)) {
// A refused or unacknowledged write means this connection
// is finished, whatever the stack thinks. Left in place it
// is found by every later attempt, believed ready, and
// fails the same way for ever. Dropped, the next one dials.
Log.w(
TAG,
"send ${message.size}B to ${Ble.hex(to)}: $kind chunk $n of ${chunk.size}B failed, mtu ${channel.mtu}"
)
drop(channel)
return@withLock false
}
}
true
}
}
/**
* The connection to [to], if we have one. One we accepted is nameless
* until their first frame, and must *not* be guessed at: guessing wrong
* hands somebody else's promise to whoever is connected. Dialling our own
* costs a second and is never wrong.
*/
private fun channelTo(to: ByteArray): Channel? =
channels.values.firstOrNull { it.beacon.contentEquals(to) }
private suspend fun writeChunk(channel: Channel, chunk: ByteArray): Boolean {
val done = CompletableDeferred<Boolean>()
channel.sent = done
val chr = characteristic ?: return false
val ok = runCatching { putOnLink(channel, chr, chunk) }.getOrElse {
Log.w(TAG, "write refused", it)
false
}
if (!ok) return false
return withTimeoutOrNull(WRITE_MS) { done.await() } ?: false
}
/** The one write, whichever end of the connection we are. */
private fun putOnLink(channel: Channel, chr: BluetoothGattCharacteristic, chunk: ByteArray): Boolean {
return if (channel.gatt != null) {
val client = channel.gatt.getService(SERVICE)?.getCharacteristic(DATA) ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
channel.gatt.writeCharacteristic(client, chunk, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
client.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
client.value = chunk
channel.gatt.writeCharacteristic(client)
}
}
} else {
val srv = server ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
srv.notifyCharacteristicChanged(channel.device, chr, false, chunk) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
chr.value = chunk
srv.notifyCharacteristicChanged(channel.device, chr, false)
}
}
}
}
// --- what arrives ------------------------------------------------------
private fun received(channel: Channel, bytes: ByteArray) {
for ((from, payload) in channel.frames.feed(bytes)) {
if (channel.beacon.all { it == ZERO }) channel.beacon = from
addresses[Ble.hex(from)] = channel.device.address
_presence.tryEmit(Heard.Presence(from, 0))
_messages.tryEmit(Heard.Message(from, payload))
}
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val data = result.scanRecord?.getManufacturerSpecificData(Ble.MANUFACTURER) ?: return
if (data.size < 8) return
val beacon = data.copyOf(8)
if (beacon.contentEquals(me)) return
addresses[Ble.hex(beacon)] = result.device.address
_presence.tryEmit(Heard.Presence(beacon, result.rssi))
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "legacy scan failed: $errorCode")
if (errorCode != SCAN_FAILED_ALREADY_STARTED) onBroken("Could not scan ($errorCode)")
}
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartFailure(errorCode: Int) {
Log.w(TAG, "legacy advertising failed: $errorCode")
if (errorCode != ADVERTISE_FAILED_ALREADY_STARTED) onBroken("Could not advertise ($errorCode)")
}
}
private val clientCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val channel = channels[gatt.device.address] ?: return
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.requestMtu(WANTED_MTU)
} else {
drop(channel)
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
channels[gatt.device.address]?.mtu = if (status == BluetoothGatt.GATT_SUCCESS) mtu else DEFAULT_MTU
gatt.discoverServices()
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val channel = channels[gatt.device.address] ?: return
val chr = gatt.getService(SERVICE)?.getCharacteristic(DATA)
if (chr == null) {
drop(channel)
return
}
gatt.setCharacteristicNotification(chr, true)
val cccd = chr.getDescriptor(CCCD)
if (cccd == null) {
channel.ready.complete(true)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
run {
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
channels[gatt.device.address]?.ready?.complete(true)
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, chr: BluetoothGattCharacteristic, status: Int) {
channels[gatt.device.address]?.sent?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(gatt: BluetoothGatt, chr: BluetoothGattCharacteristic) {
channels[gatt.device.address]?.let { received(it, chr.value ?: return) }
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, chr: BluetoothGattCharacteristic, value: ByteArray) {
channels[gatt.device.address]?.let { received(it, value) }
}
}
private val serverCallback = object : BluetoothGattServerCallback() {
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
// Their beacon arrives with their first frame; until then the
// channel is known only by address.
channels.getOrPut(device.address) {
Channel(
ByteArray(8),
device,
null
).also { it.ready.complete(true) }
}
} else {
channels[device.address]?.let { drop(it) }
}
}
override fun onMtuChanged(device: BluetoothDevice, mtu: Int) {
channels[device.address]?.mtu = mtu
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice,
requestId: Int,
chr: BluetoothGattCharacteristic,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray,
) {
val channel = channels.getOrPut(device.address) {
Channel(ByteArray(8), device, null).also { it.ready.complete(true) }
}
if (responseNeeded) {
server?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
received(channel, value)
}
override fun onDescriptorWriteRequest(
device: BluetoothDevice,
requestId: Int,
descriptor: BluetoothGattDescriptor,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray,
) {
if (responseNeeded) {
server?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
}
}
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
channels[device.address]?.sent?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
}
companion object {
private const val TAG = "OwuLink"
private const val ZERO: Byte = 0
/** 16-bit alias f077, so the advertisement stays inside 31 bytes. */
val SERVICE: UUID = UUID.fromString("0000f077-0000-1000-8000-00805f9b34fb")
val DATA: UUID = UUID.fromString("0000f078-0000-1000-8000-00805f9b34fb")
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEFAULT_MTU = 23
private const val WANTED_MTU = 517
/** ATT write header. */
private const val ATT_OVERHEAD = 3
/** An attribute value is 512 bytes at most, whatever the MTU allows. */
private const val MAX_ATTRIBUTE = 512
private const val CONNECT_MS = 15_000L
private const val WRITE_MS = 5_000L
}
}
@@ -0,0 +1,70 @@
package net.helcel.owu.crypto
/**
* Deterministic JSON, the RFC 8785 subset the ledger needs: keys sorted by
* UTF-16 code unit, strings, whole numbers, booleans, lists. Everything signed
* or hashed comes through here, never the transport encoder, so the wire
* format can change without breaking a signature.
*
* Nulls are dropped, so a field added later does not alter older bytes. Floats
* are rejected: one double has no single textual form across platforms, which
* is why coordinates are micro-degrees.
*/
object Canonical {
fun bytes(value: Any?): ByteArray = encode(value).toByteArray(Charsets.UTF_8)
fun encode(value: Any?): String = StringBuilder().also { write(it, value) }.toString()
private fun write(sb: StringBuilder, value: Any?) {
when (value) {
null -> sb.append("null")
is Boolean -> sb.append(value)
is Int, is Long, is Short, is Byte -> sb.append(value)
is String -> writeString(sb, value)
is Map<*, *> -> {
val keys = value.keys.map {
it as? String ?: throw IllegalArgumentException("non-string key: $it")
}.sorted()
sb.append('{')
var first = true
for (k in keys) {
val v = value[k] ?: continue
if (!first) sb.append(',')
first = false
writeString(sb, k)
sb.append(':')
write(sb, v)
}
sb.append('}')
}
is List<*> -> {
sb.append('[')
value.forEachIndexed { i, v ->
if (i > 0) sb.append(',')
write(sb, v)
}
sb.append(']')
}
else -> throw IllegalArgumentException("cannot canonicalise ${value::class.simpleName}")
}
}
private fun writeString(sb: StringBuilder, s: String) {
sb.append('"')
for (c in s) {
when {
c == '"' -> sb.append("\\\"")
c == '\\' -> sb.append("\\\\")
c == '\b' -> sb.append("\\b")
c == '\u000C' -> sb.append("\\f")
c == '\n' -> sb.append("\\n")
c == '\r' -> sb.append("\\r")
c == '\t' -> sb.append("\\t")
c < ' ' -> sb.append("\\u%04x".format(c.code))
else -> sb.append(c)
}
}
sb.append('"')
}
}
@@ -0,0 +1,16 @@
package net.helcel.owu.crypto
import java.security.MessageDigest
object Hash {
/** The parent hash of a genesis block: nothing came before it. */
const val ZERO = "0000000000000000000000000000000000000000000000000000000000000000"
fun sha256(bytes: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes)
fun sha256Hex(bytes: ByteArray): String = hex(sha256(bytes))
fun hex(bytes: ByteArray): String = buildString(bytes.size * 2) {
for (b in bytes) append("%02x".format(b))
}
}
@@ -0,0 +1,140 @@
package net.helcel.owu.crypto
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.io.File
import java.nio.ByteBuffer
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Who this phone is: a P-256 pair in the app's storage, sealed with an AES key
* in the Android Keystore, so a copy of the file is worth nothing elsewhere.
*
* Deliberately *not* a Keystore signing key, which can never be read back: the
* identity would die with the phone and every OwU anyone holds from you would
* be unredeemable. A backup must outlive the phone, so [export] hands the key
* over.
*/
class Identity private constructor(private val pair: KeyPair) : Signer {
override val publicKey: String = Keys.encode(pair.public)
override fun sign(data: ByteArray): String = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(data)
Keys.encodeSignature(sign())
}
/** Both halves, for a backup the user has asked for: PKCS#8 and X.509 DER. */
fun export(): Pair<ByteArray, ByteArray> = pair.private.encoded to pair.public.encoded
companion object {
private const val FILE = "identity.key"
private const val ALIAS = "owu_identity_wrap"
private const val PROVIDER = "AndroidKeyStore"
private const val TAG_BITS = 128
private const val NONCE = 12
fun load(context: Context): Identity {
val file = File(context.filesDir, FILE)
if (!file.exists()) {
val fresh = KeyPairGenerator.getInstance("EC")
.apply { initialize(ECGenParameterSpec("secp256r1")) }
.generateKeyPair()
write(file, fresh.private.encoded, fresh.public.encoded)
return Identity(fresh)
}
val (priv, pub) = read(file)
return Identity(pairOf(priv, pub))
}
/**
* Takes on the identity in a backup, replacing whatever this phone
* had. The caller asks first: the old identity is gone afterwards, and
* with it the ability to close promises made under it.
*/
fun replace(context: Context, pkcs8: ByteArray, x509: ByteArray): Identity {
val pair = pairOf(pkcs8, x509) // rejects a mismatched pair below
write(File(context.filesDir, FILE), pkcs8, x509)
return Identity(pair)
}
/**
* The pair those two encodings describe, refusing them unless the
* private half really does sign for the public one - a backup with
* mismatched halves would be an identity that cannot sign.
*/
fun pairOf(pkcs8: ByteArray, x509: ByteArray): KeyPair {
val factory = KeyFactory.getInstance("EC")
val pair = KeyPair(
factory.generatePublic(X509EncodedKeySpec(x509)),
factory.generatePrivate(PKCS8EncodedKeySpec(pkcs8)),
)
val proof = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(PROOF)
sign()
}
val good = Signature.getInstance(Keys.ALGORITHM).run {
initVerify(pair.public)
update(PROOF)
verify(proof)
}
require(good) { "the two halves of that key do not belong together" }
return pair
}
private val PROOF = "owu-identity-check".toByteArray(Charsets.UTF_8)
/** The file holds both encodings, length-prefixed, under one seal. */
private fun read(file: File): Pair<ByteArray, ByteArray> {
val bytes = file.readBytes()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(TAG_BITS, bytes.copyOf(NONCE)))
val plain = ByteBuffer.wrap(cipher.doFinal(bytes, NONCE, bytes.size - NONCE))
val priv = ByteArray(plain.int).also { plain.get(it) }
val pub = ByteArray(plain.remaining()).also { plain.get(it) }
return priv to pub
}
private fun write(file: File, pkcs8: ByteArray, x509: ByteArray) {
val plain = ByteBuffer.allocate(4 + pkcs8.size + x509.size)
.putInt(pkcs8.size).put(pkcs8).put(x509).array()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
val sealed = cipher.iv + cipher.doFinal(plain)
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.writeBytes(sealed)
if (!tmp.renameTo(file)) {
file.delete()
tmp.renameTo(file)
}
}
/** The AES key that seals the file, made once and kept in the Keystore. */
private fun wrapKey(): SecretKey {
val store = KeyStore.getInstance(PROVIDER).apply { load(null) }
(store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER)
generator.init(
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}
}
}
@@ -0,0 +1,120 @@
package net.helcel.owu.crypto
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
/**
* Public keys travel as base64 of their X.509 SubjectPublicKeyInfo DER, the
* form `PublicKey.getEncoded()` gives for an EC key on every platform. That
* string is a user's identity: it is what IOUs are issued and transferred to.
*/
object Keys {
const val ALGORITHM = "SHA256withECDSA"
fun encode(key: PublicKey): String = Base64.getEncoder().encodeToString(key.encoded)
fun decode(encoded: String): PublicKey? = try {
KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(encoded)))
} catch (e: Exception) {
null
}
/**
* True if [signature] (base64 DER) verifies [data] under [publicKey] **and**
* is in the one encoding we accept.
*
* ECDSA admits two signatures per key and message - (r, s) and (r, n - s) -
* and DER admits padding. Either lets anyone who has merely *seen* an OwU
* rewrite its head signature: still valid, different block hash, so the two
* copies look like a double-spend by their holder. Low-s minimal DER only,
* which makes a fork mean what it says: someone signed twice.
*/
fun verify(publicKey: String, data: ByteArray, signature: String): Boolean {
val key = decode(publicKey) ?: return false
val der = try {
Base64.getDecoder().decode(signature)
} catch (e: Exception) {
return false
}
if (!isCanonical(der)) return false
return try {
Signature.getInstance(ALGORITHM).run {
initVerify(key)
update(data)
verify(der)
}
} catch (e: Exception) {
false
}
}
/** A signature as it must be stored: minimal DER, low s, base64. */
fun encodeSignature(der: ByteArray): String =
Base64.getEncoder().encodeToString(canonical(der))
/** The same signature in the one accepted encoding. */
fun canonical(der: ByteArray): ByteArray {
val (r, s) = parse(der) ?: return der
return encodePair(r, if (s > HALF_ORDER) ORDER.subtract(s) else s)
}
fun isCanonical(der: ByteArray): Boolean {
val (r, s) = parse(der) ?: return false
return s <= HALF_ORDER && der.contentEquals(encodePair(r, s))
}
/** (r, s) of a DER SEQUENCE of two INTEGERs, or null if it is not one. */
private fun parse(der: ByteArray): Pair<BigInteger, BigInteger>? {
// P-256 signatures are far below 128 bytes, so only short-form
// lengths are ever legitimate here.
if (der.size < 8 || der.size > 72 || der[0] != 0x30.toByte()) return null
if ((der[1].toInt() and 0xff) != der.size - 2) return null
fun integer(at: Int): Pair<BigInteger, Int>? {
if (at + 1 >= der.size || der[at] != 0x02.toByte()) return null
val length = der[at + 1].toInt() and 0xff
if (length == 0 || at + 2 + length > der.size) return null
val bytes = der.copyOfRange(at + 2, at + 2 + length)
// Minimal encoding: no leading zero unless the next byte would
// read as negative, and never negative itself.
if (bytes[0].toInt() and 0x80 != 0) return null
if (bytes.size > 1 && bytes[0] == 0.toByte() && bytes[1].toInt() and 0x80 == 0) return null
return BigInteger(1, bytes) to at + 2 + length
}
val (r, next) = integer(2) ?: return null
val (s, end) = integer(next) ?: return null
if (end != der.size) return null
if (r.signum() <= 0 || s.signum() <= 0 || r >= ORDER || s >= ORDER) return null
return r to s
}
private fun encodePair(r: BigInteger, s: BigInteger): ByteArray {
fun integer(v: BigInteger): ByteArray {
val bytes = v.toByteArray() // already minimal and non-negative
return byteArrayOf(0x02, bytes.size.toByte()) + bytes
}
val body = integer(r) + integer(s)
return byteArrayOf(0x30, body.size.toByte()) + body
}
/** The order of the P-256 group, and the line between low and high s. */
private val ORDER = BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16)
private val HALF_ORDER = ORDER.shiftRight(1)
/**
* A short, human-comparable handle for a key: the first 64 bits of the
* SHA-256 of its DER, as four groups of hex. For display only.
*/
fun fingerprint(publicKey: String): String {
val der = try {
Base64.getDecoder().decode(publicKey)
} catch (e: Exception) {
publicKey.toByteArray()
}
return Hash.sha256Hex(der).take(16).chunked(4).joinToString(" ")
}
}
@@ -0,0 +1,13 @@
package net.helcel.owu.crypto
/**
* Something that can sign on behalf of one identity. On a device this is the
* Keystore-held key; in tests it is an in-memory pair.
*/
interface Signer {
/** The identity this signer speaks for, as [Keys.encode] gives it. */
val publicKey: String
/** ECDSA over SHA-256 of [data], returned as base64 DER. */
fun sign(data: ByteArray): String
}
@@ -0,0 +1,122 @@
package net.helcel.owu.demo
import net.helcel.owu.crypto.Keys
import net.helcel.owu.crypto.Signer
import net.helcel.owu.ledger.GeoLoc
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.store.Contact
import net.helcel.owu.store.IouStore
import net.helcel.owu.store.Template
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.util.UUID
/**
* A ledger worth looking at, debug only: its one caller sits behind
* `BuildConfig.DEBUG`, so R8 drops all of this from a release.
*
* Signed for real by throwaway keys, so these verify, trade and redeem like
* any other, and cover the states a screen can be in: held, owed, arrived
* through a third party, place-bound, not yet valid, long expired, over.
*/
object Demo {
/** A key that lives only as long as this demo needs it. */
private class SoftSigner : Signer {
private val pair = KeyPairGenerator.getInstance("EC")
.apply { initialize(ECGenParameterSpec("secp256r1")) }
.generateKeyPair()
override val publicKey: String = Keys.encode(pair.public)
override fun sign(data: ByteArray): String = Signature.getInstance(Keys.ALGORITHM).run {
initSign(pair.private)
update(data)
Keys.encodeSignature(sign())
}
}
private const val HOUR = 3600L
private const val DAY = 24 * HOUR
/** True when there is nothing here yet, and so nothing to disturb. */
fun wanted(store: IouStore): Boolean =
store.ious.value.isEmpty() && store.templates.value.isEmpty()
fun fill(store: IouStore, me: Signer) {
val now = Ledger.now()
val dana = SoftSigner()
val eli = SoftSigner()
val mo = SoftSigner()
val kim = SoftSigner()
// Two people you have named, one you have only met - so an OwU owed by
// Mo shows without the tick - and one who only ever said hello.
store.putContact(Contact(dana.publicKey, "Dana"))
store.putContact(Contact(eli.publicKey, "Eli"))
store.putMet(mo.publicKey, "Mo")
store.putMet(kim.publicKey, "Kim")
// Promises you keep ready.
listOf(
Metadata("1 Beer"),
Metadata("1 Coffee", geoloc = GeoLoc.of(46.5197, 6.6323, 150, "Café du Lac")),
Metadata("1 Ride to the airport", "Any terminal, any hour. Wake me if you must."),
Metadata("1 Heavy Hug", notAfter = now + 30 * DAY),
).forEachIndexed { i, metadata ->
store.putTemplate(Template(UUID.randomUUID().toString(), metadata, now - i * HOUR))
}
// OwUs you hold, from people who owe you.
store.put(
handedOver(
dana,
me.publicKey,
Metadata("1 Coffee", geoloc = GeoLoc.of(46.5197, 6.6323, 150, "Café du Lac")),
now - 30 * HOUR
)
)
store.put(handedOver(eli, me.publicKey, Metadata("1 Ride to the airport"), now - 26 * HOUR))
store.put(
handedOver(
dana,
me.publicKey,
Metadata("1 Lunch", "Somewhere with a terrace.", notAfter = now - DAY),
now - 20 * DAY
)
)
store.put(
handedOver(
eli,
me.publicKey,
Metadata("1 Concert ticket", notBefore = now + 2 * DAY),
now - 8 * HOUR
)
)
// One that reached you through somebody else: Mo promised it, Dana
// passed it on. Mo is no contact of yours, so it reads as a stranger's.
val third = handedOver(mo, dana.publicKey, Metadata("1 Cinema ticket"), now - 12 * HOUR)
store.put(Ledger.transfer(third, dana, me.publicKey, now - 6 * HOUR))
// OwUs you owe.
store.put(handedOver(me, dana.publicKey, Metadata("1 Massage"), now - 18 * HOUR))
store.put(handedOver(me, eli.publicKey, Metadata("2 Beers"), now - 16 * HOUR))
// And one that is over: handed home to Dana, who closed it.
val tea = handedOver(dana, me.publicKey, Metadata("1 Tea"), now - 5 * DAY)
val home = Ledger.transfer(tea, me, dana.publicKey, now - 4 * DAY)
store.put(Ledger.redeem(home, dana, now - 4 * DAY + HOUR))
}
/**
* A promise written by [debtor] and handed to [holder], which is what
* every OwU that has moved looks like: a genesis to oneself, then a
* transfer.
*/
private fun handedOver(debtor: Signer, holder: String, metadata: Metadata, at: Long): Iou =
Ledger.transfer(Ledger.issue(debtor, metadata, timestamp = at), debtor, holder, at + 60)
}
@@ -0,0 +1,25 @@
package net.helcel.owu.helper
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
fun Context.toast(text: String) = Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
fun Context.copyToClipboard(label: String, text: String) {
getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText(label, text))
}
fun Context.clipboardText(): String? =
getSystemService(ClipboardManager::class.java)?.primaryClip?.takeIf { it.itemCount > 0 }
?.getItemAt(0)?.coerceToText(this)?.toString()
private val timeFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
fun formatTime(epochSeconds: Long): String =
timeFormat.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()))
@@ -0,0 +1,20 @@
package net.helcel.owu.helper
import android.content.Context
import net.helcel.owu.ledger.Iou
/** What an OwU's own conditions say about here and now. */
object Gates {
/**
* True when the OwU names a place and we are elsewhere. Never a bar: it
* only paints the line red, since only the one who wrote it can say
* whether it counts. Without permission or a fix we cannot say we are
* away, so we do not say it.
*/
suspend fun outOfPlace(context: Context, iou: Iou): Boolean {
val geo = iou.metadata.geoloc ?: return false
if (!Locator.hasPermission(context)) return false
val loc = Locator.current(context) ?: return false
return Locator.distanceTo(loc, geo) > geo.radiusM
}
}
@@ -0,0 +1,25 @@
package net.helcel.owu.helper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.helcel.owu.crypto.Keys
import net.helcel.owu.ledger.IouJson
/** What goes in the identity QR code: who you are and how to address you. */
@Serializable
data class IdentityCard(
@SerialName("v") val version: Int = 1,
val name: String,
val key: String,
) {
fun encode(): String = IouJson.json.encodeToString(serializer(), this)
companion object {
/** Null unless [text] is a card carrying a usable key. */
fun decode(text: String): IdentityCard? = try {
IouJson.json.decodeFromString(serializer(), text).takeIf { Keys.decode(it.key) != null }
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,79 @@
package net.helcel.owu.helper
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import net.helcel.owu.ledger.GeoLoc
import kotlin.coroutines.resume
/**
* One fix, straight from the platform's LocationManager. No fused provider:
* that is Play Services.
*/
object Locator {
const val PERMISSION = Manifest.permission.ACCESS_FINE_LOCATION
fun hasPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, PERMISSION) == PackageManager.PERMISSION_GRANTED
/** Metres from [location] to the centre of [geoloc]. */
fun distanceTo(location: Location, geoloc: GeoLoc): Float {
val out = FloatArray(1)
Location.distanceBetween(location.latitude, location.longitude, geoloc.latitude, geoloc.longitude, out)
return out[0]
}
/**
* The current position, waiting up to [timeoutMs] for a fresh fix and
* falling back to the last known one. Null if the device has neither.
*/
// Lint cannot see the permission check through the suspend boundary; it is the first line.
@SuppressLint("MissingPermission")
suspend fun current(context: Context, timeoutMs: Long = 10_000): Location? {
if (!hasPermission(context)) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val provider = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.firstOrNull { runCatching { manager.isProviderEnabled(it) }.getOrDefault(false) }
?: return null
val fresh = withTimeoutOrNull(timeoutMs) { request(context, manager, provider) }
return fresh ?: runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
}
@SuppressLint("MissingPermission") // only reached through [current], which checks
private suspend fun request(context: Context, manager: LocationManager, provider: String): Location? =
suspendCancellableCoroutine { cont ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(provider, signal, context.mainExecutor) { if (cont.isActive) cont.resume(it) }
} else {
// All four methods spelled out: before API 30 none of them
// has a default, and a lambda would miss the other three.
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (cont.isActive) cont.resume(location)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {
if (cont.isActive) cont.resume(null)
}
}
@Suppress("DEPRECATION")
manager.requestSingleUpdate(provider, listener, Looper.getMainLooper())
cont.invokeOnCancellation { manager.removeUpdates(listener) }
}
}
}
@@ -0,0 +1,12 @@
package net.helcel.owu.helper
import android.content.Context
import android.content.SharedPreferences
/** The preference file opened directly, under the name and mode androidx's
* PreferenceManager uses, so the preference UI framework is not pulled in. */
fun defaultPreferences(ctx: Context): SharedPreferences =
ctx.getSharedPreferences(ctx.packageName + "_preferences", Context.MODE_PRIVATE)
@@ -0,0 +1,22 @@
package net.helcel.owu.ledger
import kotlinx.serialization.json.Json
/**
* The transport and storage encoding. Only [net.helcel.owu.crypto.Canonical]
* bytes are ever signed, so this can grow fields freely.
*/
object IouJson {
val json = Json {
classDiscriminator = "action"
encodeDefaults = true
explicitNulls = false
ignoreUnknownKeys = true
}
fun encode(iou: Iou): String = json.encodeToString(Iou.serializer(), iou)
fun decode(text: String): Iou = json.decodeFromString(Iou.serializer(), text)
fun encode(agreement: ExchangeAgreement): String = json.encodeToString(ExchangeAgreement.serializer(), agreement)
fun decodeAgreement(text: String): ExchangeAgreement = json.decodeFromString(ExchangeAgreement.serializer(), text)
}
@@ -0,0 +1,160 @@
package net.helcel.owu.ledger
import net.helcel.owu.crypto.Signer
import java.util.UUID
class LedgerException(message: String) : Exception(message)
/**
* The only way blocks get made. Each operation checks what it needs from the
* current state, signs, appends, and then has [Verifier] accept the result
* before returning it, so a caller can never be handed a chain that a peer
* would reject.
*/
object Ledger {
fun now(): Long = System.currentTimeMillis() / 1000
/**
* Writes a promise. It starts held by the person who made it - an OwU
* towards yourself - and becomes someone else's when it is traded away.
* [creditor] names another holder only where a chain has to be built
* whole, as in tests and fixtures.
*/
fun issue(
signer: Signer,
metadata: Metadata,
creditor: String = signer.publicKey,
id: String = UUID.randomUUID().toString(),
timestamp: Long = now(),
): Iou {
val nb = metadata.notBefore
val na = metadata.notAfter
if (nb != null && na != null && nb > na) throw LedgerException("window closes before it opens")
val unsigned = Block.Issue(
timestamp = timestamp,
debtor = signer.publicKey,
creditor = creditor,
metadataHash = metadata.hash(),
)
val genesis = unsigned.copy(signature = signer.sign(unsigned.signedBytes(id)))
return accept(Iou(id, metadata, listOf(genesis)))
}
fun transfer(iou: Iou, signer: Signer, transferee: String, timestamp: Long = now()): Iou {
val state = active(iou)
if (state.holder != signer.publicKey) throw LedgerException("only the holder can transfer")
if (transferee == signer.publicKey) throw LedgerException("cannot transfer to yourself")
val unsigned = Block.Transfer(
sequence = state.length,
timestamp = timestamp,
parentHash = state.headHash,
transferor = signer.publicKey,
transferee = transferee,
)
return append(iou, unsigned.copy(signature = signer.sign(unsigned.signedBytes(iou.id))))
}
/**
* The debtor closes their own promise, which is what becomes of an OwU
* handed back to the person who made it. Nothing can follow.
*/
fun redeem(iou: Iou, signer: Signer, timestamp: Long = now()): Iou {
val state = active(iou)
if (state.debtor != signer.publicKey) throw LedgerException("only the debtor can close a promise")
if (state.holder != signer.publicKey) throw LedgerException("the OwU has to be back with you first")
val unsigned = Block.Redeemed(
sequence = state.length,
timestamp = timestamp,
parentHash = state.headHash,
debtor = signer.publicKey,
)
return append(iou, unsigned.copy(signature = signer.sign(unsigned.signedBytes(iou.id))))
}
/**
* Step one of a swap: the holder of [mine] offers all of it for all of
* [theirs], signing the left side. Either bundle may hold several OwUs
* and they stand or fall together. [theirs] is the counterparty's side as
* we last saw it.
*/
fun proposeExchange(
mine: List<Iou>,
theirs: List<Iou>,
signer: Signer,
id: String = UUID.randomUUID().toString(),
timestamp: Long = now(),
): ExchangeAgreement {
if (mine.isEmpty() || theirs.isEmpty()) throw LedgerException("a swap needs something on both sides")
val mineStates = mine.map { it to active(it) }
val theirsStates = theirs.map { it to active(it) }
val theirIds = theirs.map { it.id }.toSet()
if (mine.any { it.id in theirIds }) throw LedgerException("cannot swap an OwU with itself")
if (mineStates.any { it.second.holder != signer.publicKey }) throw LedgerException("only the holder can offer an OwU")
if (theirsStates.any { it.second.holder == signer.publicKey }) throw LedgerException("you already hold the other OwU")
val holder = signer.publicKey
val theirHolder = theirsStates.first().second.holder
if (theirsStates.any { it.second.holder != theirHolder }) throw LedgerException("their side is held by more than one person")
val draft = ExchangeAgreement(
id = id,
timestamp = timestamp,
left = ExchangeSide(holder, mineStates.map { ExchangeRef(it.first.id, it.second.headHash) }),
right = ExchangeSide(theirHolder, theirsStates.map { ExchangeRef(it.first.id, it.second.headHash) }),
)
return draft.copy(left = draft.left.copy(signature = signer.sign(draft.signingBytes())))
}
/**
* Step two: the counterparty, holding the right-hand side, countersigns.
* [mine] is their copy of every OwU on it, each of which must still be at
* the head the proposal was made against.
*/
fun acceptExchange(proposal: ExchangeAgreement, mine: List<Iou>, signer: Signer): ExchangeAgreement {
if (proposal.left.signature == null) throw LedgerException("proposal is not signed by the proposer")
if (proposal.right.signature != null) throw LedgerException("proposal is already accepted")
if (proposal.right.holder != signer.publicKey) throw LedgerException("proposal names a different holder")
val byId = mine.associateBy { it.id }
if (byId.size != proposal.right.ious.size || proposal.right.ious.any { it.iouId !in byId })
throw LedgerException("proposal is not for these OwUs")
proposal.right.ious.forEach { ref ->
val state = active(byId.getValue(ref.iouId))
if (state.holder != signer.publicKey) throw LedgerException("only the holder can accept")
if (ref.headHash != state.headHash) throw LedgerException("OwU has changed since the proposal")
}
return proposal.copy(right = proposal.right.copy(signature = signer.sign(proposal.signingBytes())))
}
/** Step three, run once per OwU by whoever has the completed agreement. */
fun applyExchange(iou: Iou, agreement: ExchangeAgreement): Iou {
if (!agreement.complete) throw LedgerException("agreement is not signed by both sides")
val side = agreement.side(iou.id) ?: throw LedgerException("agreement does not name this OwU")
val state = active(iou)
if (side.ref(iou.id)!!.headHash != state.headHash) throw LedgerException("OwU has changed since the agreement")
val block = Block.Transfer(
sequence = state.length,
timestamp = agreement.timestamp,
parentHash = state.headHash,
transferor = side.holder,
transferee = agreement.other(iou.id)!!.holder,
agreement = agreement,
signature = side.signature!!,
)
return append(iou, block)
}
private fun valid(iou: Iou): IouState = when (val v = Verifier.verify(iou)) {
is Verdict.Valid -> v.state
is Verdict.Invalid -> throw LedgerException("invalid chain at block ${v.sequence}: ${v.reason}")
}
private fun active(iou: Iou): IouState = valid(iou).also {
if (it.status != Status.ACTIVE) throw LedgerException("OwU is ${it.status}")
}
private fun append(iou: Iou, block: Block): Iou = accept(iou.copy(chain = iou.chain + block))
private fun accept(iou: Iou): Iou {
valid(iou)
return iou
}
}
@@ -0,0 +1,290 @@
package net.helcel.owu.ledger
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.helcel.owu.crypto.Canonical
import net.helcel.owu.crypto.Hash
/**
* One IOU: a signed chain of custody. The chain is only meaningful once
* [Verifier.verify] has accepted it; nothing here checks anything.
*/
@Serializable
data class Iou(
@SerialName("iou_id") val id: String,
val metadata: Metadata,
@SerialName("ledger_chain") val chain: List<Block>,
) {
val head: Block get() = chain.last()
fun headHash(): String = head.hash(id)
}
/**
* What the IOU is. Covered by the genesis signature through [hash], so a
* "1 Heavy Hug" cannot become "1 Car" after issue.
*
* [notBefore]/[notAfter] (epoch seconds) bound redemption. Like [geoloc] they
* are shown, not enforced: clocks are self-reported, and the debtor decides.
*/
@Serializable
data class Metadata(
@SerialName("display_title") val title: String,
/** Whatever else the promise needs said: terms, an occasion, a joke. */
@SerialName("description") val description: String? = null,
@SerialName("template_id") val templateId: String? = null,
val geoloc: GeoLoc? = null,
@SerialName("not_before") val notBefore: Long? = null,
@SerialName("not_after") val notAfter: Long? = null,
) {
// All of it signed with the genesis block, description included: neither
// side can edit what was promised.
fun canonical(): Map<String, Any?> = mapOf(
"display_title" to title,
"description" to description,
"template_id" to templateId,
"geoloc" to geoloc?.canonical(),
"not_before" to notBefore,
"not_after" to notAfter,
)
fun hash(): String = Hash.sha256Hex(Canonical.bytes(canonical()))
val hasWindow: Boolean get() = notBefore != null || notAfter != null
/** Where [now] falls relative to the redemption window. */
fun timeGate(now: Long = Ledger.now()): TimeGate = when {
notBefore != null && now < notBefore -> TimeGate.NotYet(notBefore)
notAfter != null && now > notAfter -> TimeGate.Expired(notAfter)
else -> TimeGate.Open
}
}
sealed class TimeGate {
object Open : TimeGate()
data class NotYet(val opensAt: Long) : TimeGate()
data class Expired(val closedAt: Long) : TimeGate()
}
/** Where a promise is to be kept. Micro-degrees, so the signed form is
* integer-only. Never verified: a hint the app shows, not a rule. */
@Serializable
data class GeoLoc(
@SerialName("lat_e6") val latE6: Int,
@SerialName("lon_e6") val lonE6: Int,
@SerialName("radius_m") val radiusM: Int,
val label: String? = null,
) {
val latitude: Double get() = latE6 / 1e6
val longitude: Double get() = lonE6 / 1e6
fun canonical(): Map<String, Any?> = mapOf(
"lat_e6" to latE6,
"lon_e6" to lonE6,
"radius_m" to radiusM,
"label" to label,
)
companion object {
fun of(latitude: Double, longitude: Double, radiusM: Int, label: String? = null) = GeoLoc(
latE6 = Math.round(latitude * 1e6).toInt(),
lonE6 = Math.round(longitude * 1e6).toInt(),
radiusM = radiusM,
label = label,
)
}
}
enum class Action { ISSUE, TRANSFER, REDEEMED }
/**
* One state change; the wire `action` is the subclass's serial name. Bound to
* its IOU (the id is signed) and to its predecessor via [parentHash], the
* SHA-256 of the previous block's canonical form, signature included. By hash
* and not by the raw signature, so ECDSA malleability cannot make two
* byte-different chains of one history.
*/
@Serializable
sealed class Block {
abstract val sequence: Int
abstract val timestamp: Long
abstract val parentHash: String
abstract val signature: String
abstract val action: Action
/** Fields specific to this action, without the common ones or the signature. */
protected abstract fun fields(): Map<String, Any?>
/** The identity whose key must have produced [signature], or null if the block cannot say. */
abstract fun signer(iouId: String): String?
/** Everything the signature covers, as a canonical map. */
fun payload(iouId: String): Map<String, Any?> = fields() + mapOf(
"iou_id" to iouId,
"sequence" to sequence,
"action" to action.name,
"timestamp" to timestamp,
"parent_hash" to parentHash,
)
/** The bytes [signature] is over. The payload, unless a swap signs its agreement instead. */
open fun signedBytes(iouId: String): ByteArray = Canonical.bytes(payload(iouId))
/** The value the next block's [parentHash] must carry. */
fun hash(iouId: String): String =
Hash.sha256Hex(Canonical.bytes(payload(iouId) + ("signature" to signature)))
/** Block 0: the debtor writes the OwU and hands it to the creditor. */
@Serializable
@SerialName("ISSUE")
data class Issue(
override val sequence: Int = 0,
override val timestamp: Long,
@SerialName("parent_hash") override val parentHash: String = Hash.ZERO,
@SerialName("debtor_pub_key") val debtor: String,
@SerialName("creditor_pub_key") val creditor: String,
@SerialName("metadata_hash") val metadataHash: String,
override val signature: String = "",
) : Block() {
override val action get() = Action.ISSUE
override fun fields() = mapOf(
"debtor_pub_key" to debtor,
"creditor_pub_key" to creditor,
"metadata_hash" to metadataHash,
)
override fun signer(iouId: String) = debtor
}
/**
* The holder passes the OwU on: plain, a gift or a hand-back.
*
* With an [agreement], one half of a swap. The same agreement lands on
* both chains, each block signed by that chain's holder with their
* signature from it, so once both have signed either party can append
* both, and neither half is valid alone. That is why such a block signs
* the agreement and not its own payload: one signature has to commit to
* both sides.
*/
@Serializable
@SerialName("TRANSFER")
data class Transfer(
override val sequence: Int,
override val timestamp: Long,
@SerialName("parent_hash") override val parentHash: String,
@SerialName("transferor_pub_key") val transferor: String,
@SerialName("transferee_pub_key") val transferee: String,
val agreement: ExchangeAgreement? = null,
override val signature: String = "",
) : Block() {
override val action get() = Action.TRANSFER
// A null agreement is dropped by Canonical, so a plain hand-over signs
// exactly the two keys and the common fields.
override fun fields() = mapOf(
"transferor_pub_key" to transferor,
"transferee_pub_key" to transferee,
"agreement" to agreement?.canonical(),
)
override fun signer(iouId: String) = transferor
override fun signedBytes(iouId: String) =
agreement?.signingBytes() ?: super.signedBytes(iouId)
}
/** The debtor honours the promise: the OwU is redeemed. Terminal. */
@Serializable
@SerialName("REDEEMED")
data class Redeemed(
override val sequence: Int,
override val timestamp: Long,
@SerialName("parent_hash") override val parentHash: String,
@SerialName("debtor_pub_key") val debtor: String,
override val signature: String = "",
) : Block() {
override val action get() = Action.REDEEMED
override fun fields() = mapOf("debtor_pub_key" to debtor)
override fun signer(iouId: String) = debtor
}
}
/** One OwU in an exchange, pinned to the head it was agreed against. */
@Serializable
data class ExchangeRef(
@SerialName("iou_id") val iouId: String,
@SerialName("head_hash") val headHash: String,
) {
fun canonical(): Map<String, Any?> = mapOf("iou_id" to iouId, "head_hash" to headHash)
}
/** One party to an exchange: all they put in, and their consent to the whole.
* Five beers and a hug are one deal, signed once, all or nothing. */
@Serializable
data class ExchangeSide(
@SerialName("holder_pub_key") val holder: String,
val ious: List<ExchangeRef> = emptyList(),
/** The holder's signature over [ExchangeAgreement.signingBytes]; null until they have signed. */
val signature: String? = null,
) {
/** Ordered, so both sides sign the same bytes whatever order they built it in. */
fun canonical(): Map<String, Any?> = mapOf(
"holder_pub_key" to holder,
"ious" to ious.sortedBy { it.iouId }.map { it.canonical() },
)
fun ref(iouId: String): ExchangeRef? = ious.firstOrNull { it.iouId == iouId }
fun holds(iouId: String): Boolean = ref(iouId) != null
}
/** [left] for [right], both holders signing the same bytes: the core without
* either signature. Every OwU is pinned to a head hash, so one of them
* moving before the swap lands voids the whole bundle. */
@Serializable
data class ExchangeAgreement(
@SerialName("exchange_id") val id: String,
val timestamp: Long,
val left: ExchangeSide,
val right: ExchangeSide,
) {
/** The core with both signatures, so a block hash covers the consent that made it valid. */
fun canonical(): Map<String, Any?> = mapOf(
"exchange_id" to id,
"timestamp" to timestamp,
"left" to left.canonical() + ("signature" to left.signature),
"right" to right.canonical() + ("signature" to right.signature),
)
fun signingBytes(): ByteArray = Canonical.bytes(
mapOf(
"exchange_id" to id,
"timestamp" to timestamp,
"left" to left.canonical(),
"right" to right.canonical(),
)
)
val complete: Boolean get() = left.signature != null && right.signature != null
fun side(iouId: String): ExchangeSide? = when {
left.holds(iouId) -> left
right.holds(iouId) -> right
else -> null
}
fun other(iouId: String): ExchangeSide? = when {
left.holds(iouId) -> right
right.holds(iouId) -> left
else -> null
}
}
enum class Status { ACTIVE, REDEEMED }
/** What a valid chain says right now. */
data class IouState(
val debtor: String,
val holder: String,
val status: Status,
val length: Int,
val headHash: String,
)
@@ -0,0 +1,113 @@
package net.helcel.owu.ledger
import net.helcel.owu.crypto.Hash
import net.helcel.owu.crypto.Keys
sealed class Verdict {
data class Valid(val state: IouState) : Verdict()
data class Invalid(val sequence: Int, val reason: String) : Verdict()
val stateOrNull: IouState? get() = (this as? Valid)?.state
}
/**
* Decides whether a received chain is what it claims to be. Every rule that
* makes a chain acceptable lives here and nowhere else; the builders in
* [Ledger] run their output through it rather than trusting themselves.
*/
object Verifier {
private class Rejected(val sequence: Int, val reason: String) : Exception(reason)
fun verify(iou: Iou): Verdict = try {
Verdict.Valid(walk(iou))
} catch (e: Rejected) {
Verdict.Invalid(e.sequence, e.reason)
}
private fun walk(iou: Iou): IouState {
val chain = iou.chain
if (chain.isEmpty()) throw Rejected(0, "empty chain")
val genesis = chain[0] as? Block.Issue ?: throw Rejected(0, "first block is not an ISSUE")
if (genesis.sequence != 0) throw Rejected(0, "genesis sequence is not 0")
if (genesis.parentHash != Hash.ZERO) throw Rejected(0, "genesis has a parent")
if (genesis.metadataHash != iou.metadata.hash()) throw Rejected(0, "metadata does not match its hash")
checkSignature(iou.id, genesis)
var state = IouState(
debtor = genesis.debtor,
holder = genesis.creditor,
status = Status.ACTIVE,
length = 1,
headHash = genesis.hash(iou.id),
)
for (n in 1 until chain.size) {
val block = chain[n]
if (block.sequence != n) throw Rejected(n, "sequence is ${block.sequence}, expected $n")
if (block.parentHash != state.headHash) throw Rejected(n, "parent hash does not match previous block")
if (state.status == Status.REDEEMED) throw Rejected(n, "block after redemption")
checkSignature(iou.id, block)
state = when (block) {
is Block.Issue -> throw Rejected(n, "a second ISSUE")
is Block.Transfer -> transfer(n, iou.id, block, state)
is Block.Redeemed -> redeemed(n, block, state)
}.copy(length = n + 1, headHash = block.hash(iou.id))
}
return state
}
private fun checkSignature(iouId: String, block: Block) {
val signer = block.signer(iouId) ?: throw Rejected(block.sequence, "block names no signer for this OwU")
if (!Keys.verify(signer, block.signedBytes(iouId), block.signature))
throw Rejected(block.sequence, "signature does not verify")
}
/**
* A hand-over. The three rules above the agreement hold whether it is a
* gift or half a swap; the rest bind this block to the other chain's.
*/
private fun transfer(n: Int, iouId: String, b: Block.Transfer, s: IouState): IouState {
if (s.status != Status.ACTIVE) throw Rejected(n, "transfer of an OwU that is ${s.status}")
if (b.transferor != s.holder) throw Rejected(n, "transferor is not the holder")
if (b.transferee == b.transferor) throw Rejected(n, "transfer to self")
val a = b.agreement ?: return s.copy(holder = b.transferee)
val mine = a.side(iouId) ?: throw Rejected(n, "agreement does not name this OwU")
val theirs = a.other(iouId)!!
if (theirs.holds(iouId)) throw Rejected(n, "agreement swaps an OwU with itself")
// The block says who it moves between and the agreement says the same,
// or one of the two is lying about the deal this signature covers.
if (mine.holder != b.transferor) throw Rejected(n, "agreement is not the transferor's side")
if (theirs.holder != b.transferee) throw Rejected(n, "agreement sends it to somebody else")
if (theirs.ious.isEmpty()) throw Rejected(n, "exchange for nothing")
// Every OwU in the bundle is pinned; this one has to be at the head it
// was signed against, or the deal both sides agreed to has changed.
if (mine.ref(iouId)!!.headHash != b.parentHash) throw Rejected(
n,
"agreement was made against a different head"
)
if (b.timestamp != a.timestamp) throw Rejected(n, "block timestamp differs from agreement")
if (!a.complete) throw Rejected(n, "agreement is not signed by both sides")
if (b.signature != mine.signature) throw Rejected(n, "block signature is not this side's agreement signature")
// Ours was checked in checkSignature; the counterparty's consent is
// what makes the swap binding, so it is checked here.
if (!Keys.verify(theirs.holder, a.signingBytes(), theirs.signature!!))
throw Rejected(n, "counterparty signature does not verify")
return s.copy(holder = b.transferee)
}
/**
* A promise is closed by the one who made it, and only once it has come
* back to them - which is what redeeming is: the OwU is handed home and
* its maker voids it. There is no state in between.
*/
private fun redeemed(n: Int, b: Block.Redeemed, s: IouState): IouState {
if (s.status != Status.ACTIVE) throw Rejected(n, "redeeming an OwU that is ${s.status}")
if (b.debtor != s.debtor) throw Rejected(n, "redeemed by someone who is not the debtor")
if (s.holder != s.debtor) throw Rejected(n, "redeemed while somebody else held it")
return s.copy(status = Status.REDEEMED)
}
}
@@ -0,0 +1,21 @@
package net.helcel.owu.peer
import net.helcel.owu.ledger.Iou
/**
* What a finished table amounted to, for whatever says so on screen. One per
* protocol step, not per event: a redemption reaches the debtor as a gift and
* a receipt together, and that is one thing that happened.
*/
data class Outcome(
/** Their public key, when the handshake got that far. */
val peer: String?,
val gave: List<Iou> = emptyList(),
val got: List<Iou> = emptyList(),
val redeemed: List<Iou> = emptyList(),
/** Or their "no", to being asked to the table or to what was on it. */
val declined: PeerMessage.Asked? = null,
) {
val isEmpty: Boolean
get() = gave.isEmpty() && got.isEmpty() && redeemed.isEmpty() && declined == null
}
@@ -0,0 +1,565 @@
package net.helcel.owu.peer
import net.helcel.owu.crypto.Canonical
import net.helcel.owu.crypto.Hash
import net.helcel.owu.crypto.Keys
import net.helcel.owu.crypto.Signer
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.Block
import net.helcel.owu.ledger.ExchangeAgreement
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouState
import net.helcel.owu.ledger.Ledger
import net.helcel.owu.ledger.LedgerException
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Status
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import net.helcel.owu.store.IouStore
import net.helcel.owu.store.Merge
import java.security.SecureRandom
/**
* One side of a session, knowing nothing of Bluetooth. Feed it messages and
* user actions; it returns what to send and what happened. Run one thread at
* a time by the transport.
*
* After HELLO/AUTH the peer is proven. Then: a **table**, each side putting
* down an OwU or nothing and both accepting, or a **redemption**, a holder
* asking and the debtor honouring. Giving, issuing and swapping are the table.
*/
class PeerEngine(
private val store: IouStore,
private val signer: Signer,
private val myName: String,
) {
sealed class Event {
data class PeerIdentified(val card: IdentityCard) : Event()
/** The peer started over (new HELLO); anything pending with them is void. */
object Restarted : Event()
/** The table changed: what is on it, and who has accepted it as it stands. */
data class Tabled(val table: Table) : Event()
/** Both accepted and it happened: what left, what arrived, either possibly nothing. */
data class Done(val gave: List<Iou> = emptyList(), val got: List<Iou> = emptyList()) : Event()
/** A promise came home and was closed: mine to file, or theirs, now spent. */
data class Redeemed(val iou: Iou) : Event()
/** They have opened a table with us and would like us at it. */
data class Invited(val card: IdentityCard) : Event()
/** They said no, to being asked to the table or to what was on it. */
data class Declined(val card: IdentityCard, val to: PeerMessage.Asked) : Event()
data class Failed(val reason: String) : Event()
}
data class Step(val send: List<PeerMessage> = emptyList(), val events: List<Event> = emptyList())
/** The table as this side sees it. Each side is a bundle: five beers and
* two hugs are one deal, accepted once, moving together or not at all. */
data class Table(
val mine: List<Iou> = emptyList(),
val theirs: List<Iou> = emptyList(),
/** I have said yes to the table as it stands. */
val accepted: Boolean = false,
/** They have said yes to the table as it stands. */
val theyAccepted: Boolean = false,
/** Their OwU contradicts the copy I hold: signed over twice. Nothing
* may be accepted against it. */
val conflict: Boolean = false,
) {
val empty: Boolean get() = mine.isEmpty() && theirs.isEmpty()
}
val me: String get() = signer.publicKey
private var myNonce: String = freshNonce()
private fun freshNonce() = ByteArray(16).also { SecureRandom().nextBytes(it) }.let { Hash.hex(it) }
/** The peer, once they have proven they hold their key. */
var peer: IdentityCard? = null
private set
private var peerHello: PeerMessage.Hello? = null
private var earlyAuth: PeerMessage.Auth? = null
private var myOffer: List<Iou> = emptyList()
private var theirOffer: List<Iou> = emptyList()
/** The deal each side has accepted, if any; stale as soon as the table changes. */
private var myAccepted: String? = null
private var theirAccepted: String? = null
/** OwUs minted from templates for this table, until they leave or are dropped. */
private val minted = mutableSetOf<String>()
/** Their OwU contradicts a copy we hold; set when it lands on the table. */
private var theirConflict = false
/** Their half-signed swap, held until I accept too. */
private var theirProposal: ExchangeAgreement? = null
/** My own half-signed swap for this table, so it is signed only once. */
private var myProposal: ExchangeAgreement? = null
fun start(): Step = Step(send = listOf(myHello()))
fun onMessage(m: PeerMessage): Step = try {
when (m) {
is PeerMessage.Hello -> hello(m)
is PeerMessage.Auth -> auth(m)
else -> {
if (peer == null) unknown()
else when (m) {
is PeerMessage.Invite -> invited(m)
is PeerMessage.Decline -> declined(m)
is PeerMessage.Table -> tabled(m)
is PeerMessage.Accept -> accepted(m)
is PeerMessage.Give -> given(m)
is PeerMessage.Redeemed -> redeemed(m)
is PeerMessage.Hello, is PeerMessage.Auth -> throw IllegalStateException("unreachable")
}
}
}
} catch (e: Exception) {
Step(events = listOf(Event.Failed(e.message ?: e.toString())))
}
// --- handshake ---------------------------------------------------------
/** Every HELLO is answered with AUTH, so a peer that missed our answer can
* ask again. Only a *new* nonce from a verified peer means they restarted. */
private fun hello(m: PeerMessage.Hello): Step {
if (Keys.decode(m.identity.key) == null) throw IllegalStateException("peer key unreadable")
if (m.identity.key == me) throw IllegalStateException("peer is this device")
val previous = peerHello
if (previous != null && previous.identity.key != m.identity.key) throw IllegalStateException("HELLO from a different key")
val auth = PeerMessage.Auth(signer.sign(authBytes(m.nonce, me)))
if (peer != null && previous != null && previous.nonce != m.nonce) {
reset()
peerHello = m
return Step(send = listOf(myHello(), auth), events = listOf(Event.Restarted))
}
peerHello = m
// Their AUTH may have overtaken their HELLO; now it can be checked.
val early = earlyAuth?.also { earlyAuth = null }
if (early != null && peer == null && verifies(m, early)) {
peer = m.identity
store.putMet(m.identity.key, m.identity.name)
return Step(send = listOf(auth), events = listOf(Event.PeerIdentified(m.identity)))
}
return Step(send = listOf(auth))
}
private fun verifies(h: PeerMessage.Hello, a: PeerMessage.Auth): Boolean =
Keys.verify(h.identity.key, authBytes(myNonce, h.identity.key), a.signature)
/** Our HELLO again, for a peer that has not answered; same nonce, so nothing restarts. */
fun reintroduce(): Step = Step(send = listOf(myHello()))
/**
* They talk as though we knew them and we do not: their session outlived
* ours, or our radio restarted under them. Dropping it leaves them waiting
* for ever - the one deadlock this protocol had. So say who we are: an
* unseen nonce restarts them, and they can try again.
*/
private fun unknown(): Step = Step(send = listOf(myHello()))
private fun myHello() = PeerMessage.Hello(IdentityCard(name = myName, key = me), myNonce)
private fun reset() {
abandon()
peer = null
peerHello = null
earlyAuth = null
myNonce = freshNonce()
clearTable()
}
private fun auth(m: PeerMessage.Auth): Step {
val h = peerHello
if (h == null) {
// Nothing to check it against yet; keep it for when their HELLO arrives.
earlyAuth = m
return Step()
}
if (!verifies(h, m)) throw IllegalStateException("peer failed to prove its key")
// A repeat of a good AUTH (they answered our HELLO twice) changes nothing.
if (peer != null) return Step()
peer = h.identity
// The key is proven; remember the name they claim so screens read as
// people rather than hashes.
store.putMet(h.identity.key, h.identity.name)
return Step(events = listOf(Event.PeerIdentified(h.identity)))
}
private val peerKey: String get() = peer?.key ?: throw IllegalStateException("no peer")
// --- the table ---------------------------------------------------------
fun table(): Table = Table(
mine = myOffer,
theirs = theirOffer,
accepted = myAccepted != null && myAccepted == deal(),
theyAccepted = theirAccepted != null && theirAccepted == deal(),
conflict = theirConflict,
)
/**
* My side of the table: [held] as they are, plus one fresh OwU per [mint]
* entry - five beers is the beer template five times. An empty call takes
* my side off. Changing the table withdraws both yeses, since nobody is
* held to a yes given to something else.
*/
fun put(held: List<Iou> = emptyList(), mint: List<Metadata> = emptyList()): Step {
held.forEach {
if (active(it).holder != me) throw LedgerException("you do not hold that OwU")
}
// Minted here, not by the caller, so a bundle that never leaves takes
// its fresh ious with it.
val fresh = mint.map { Ledger.issue(signer, it).also { iou -> store.put(iou) } }
val offer = held + fresh
discardMinted(keep = offer.map { it.id }.toSet())
minted += fresh.map { it.id }
myOffer = offer
forget()
return Step(send = listOf(PeerMessage.Table(me, offer)), events = listOf(Event.Tabled(table())))
}
/** Mints one promise from a template onto the table alone: writing one mid-trade. */
fun putNew(metadata: Metadata): Step = put(mint = listOf(metadata))
/** Nothing came of the table: forget anything minted for it. */
fun abandon() {
discardMinted(keep = emptySet())
}
/** Drops ious minted here and not in [keep], but only while still a bare
* genesis in my hands. Once one has moved it is somebody's promise. */
private fun discardMinted(keep: Set<String>) {
val going = minted - keep
minted.retainAll(keep)
going.forEach { id ->
val iou = store.ious.value[id] ?: return@forEach
val state = Verifier.verify(iou).stateOrNull ?: return@forEach
if (iou.chain.size == 1 && state.holder == me && state.debtor == me) store.remove(id)
}
}
/**
* Their side of the table, from a TABLE or riding on their acceptance.
*
* Checked against what we hold *before* anyone agrees: the one moment a
* double-spend is caught without the debtor in the room. Contradicting
* histories are one such; so is an OwU offered at a head we know has moved
* on. One bad OwU taints the bundle, since it moves as one.
*/
private fun adoptTheirs(ious: List<Iou>) {
ious.forEach {
if (active(it).holder != peerKey) throw IllegalStateException("they offer an OwU they do not hold")
}
if (ious.map { it.id }.toSet().size != ious.size) throw IllegalStateException("the same OwU twice")
theirOffer = ious
theirConflict = ious.any { iou ->
when (store.compare(iou)) {
is Merge.Fork -> true
Merge.Unchanged -> iou.chain.size < (store.ious.value[iou.id]?.chain?.size ?: 0)
else -> false
}
}
}
/** Sent on opening a table. Carries and changes nothing; being asked is all of it. */
fun invite(): Step = Step(send = listOf(PeerMessage.Invite(me)))
private fun invited(m: PeerMessage.Invite): Step {
val card = peer ?: throw IllegalStateException("no peer")
if (m.key != card.key) throw IllegalStateException("that invitation is signed by somebody else")
return Step(events = listOf(Event.Invited(card)))
}
/** Turn them down. Refusing the table clears it both sides, and a minted
* OwU that never left goes with it. */
fun decline(to: PeerMessage.Asked): Step {
val send = listOf(PeerMessage.Decline(me, to))
if (to != PeerMessage.Asked.TABLE) return Step(send = send)
abandon()
clearTable()
return Step(send = send, events = listOf(Event.Tabled(table())))
}
private fun declined(m: PeerMessage.Decline): Step {
val card = peer ?: throw IllegalStateException("no peer")
if (m.key != card.key) throw IllegalStateException("that refusal is signed by somebody else")
val said = Event.Declined(card, m.to)
if (m.to != PeerMessage.Asked.TABLE) return Step(events = listOf(said))
abandon()
clearTable()
return Step(events = listOf(said, Event.Tabled(table())))
}
private fun tabled(m: PeerMessage.Table): Step {
adoptTheirs(m.ious)
forget()
return Step(events = listOf(Event.Tabled(table())))
}
/** Yes to the table as it stands. Two yeses to the same table and it
* happens: a swap if both brought something, a transfer if one did. */
fun accept(): Step {
val deal = deal() ?: throw LedgerException("there is nothing on the table")
if (theirConflict) throw LedgerException("that OwU contradicts the copy you hold; it has been signed over twice")
myAccepted = deal
val step = advance()
if (step.send.isNotEmpty()) return step
// My side rides along with my yes, so losing the TABLE cannot strand it.
return Step(
send = listOf(PeerMessage.Accept(me, deal, myOffer)),
events = listOf(Event.Tabled(table())),
)
}
private fun accepted(m: PeerMessage.Accept): Step {
// An acceptance carries its own side, so it stands alone. The TABLE
// before it may never have arrived - a big fragmented message where
// this one is small - which used to leave the table empty and the
// acceptance refused as "they accepted with nothing on the table".
if (m.ious.isNotEmpty() && m.ious.map { it.id }.toSet() != theirOffer.map { it.id }.toSet()) {
adoptTheirs(m.ious)
}
val deal = deal() ?: throw IllegalStateException("they accepted with nothing on the table")
if (m.deal != deal) throw IllegalStateException("the table changed while they were accepting")
m.agreement?.let { a ->
if (a.complete) return complete(a, m.ious)
checkProposal(a)
theirProposal = a
}
theirAccepted = deal
val step = advance()
if (step.send.isNotEmpty() || step.events.isNotEmpty()) return step
return Step(events = listOf(Event.Tabled(table())))
}
/**
* What my yes now allows. For a swap it carries a signature: the side
* holding the lowest OwU id half-signs over both bundles, the other
* countersigns, and countersigning is what makes it happen.
*/
private fun advance(): Step {
val deal = deal() ?: return Step()
if (myAccepted != deal) return Step()
val mine = myOffer
val theirs = theirOffer
return when {
mine.isNotEmpty() && theirs.isNotEmpty() -> {
val proposal = theirProposal
when {
proposal != null -> {
val agreement = Ledger.acceptExchange(proposal, mine, signer)
val myNew = mine.map { Ledger.applyExchange(it, agreement) }
val theirNew = theirs.map { Ledger.applyExchange(it, agreement) }
(myNew + theirNew).forEach { store.put(it) }
clearTable()
val done = Event.Done(gave = myNew, got = theirNew)
val accept = PeerMessage.Accept(me, deal, mine, agreement)
val home = closeIfHome(theirNew)
Step(send = listOf(accept) + home.send, events = listOf(done) + home.events)
}
// Mine signs first, once. The lowest id decides, so both agree who.
mine.minOf { it.id } < theirs.minOf { it.id } && myProposal == null -> {
val proposed = Ledger.proposeExchange(mine, theirs, signer)
myProposal = proposed
Step(send = listOf(PeerMessage.Accept(me, deal, mine, proposed)))
}
// Their side signs first, or mine already did; wait for it.
else -> Step()
}
}
// Only my side has anything: once they have said yes too, it is theirs.
mine.isNotEmpty() && theirAccepted == deal -> {
val given =
mine.map { Ledger.transfer(store.ious.value[it.id] ?: it, signer, peerKey).also(store::put) }
clearTable()
Step(send = listOf(PeerMessage.Give(given)), events = listOf(Event.Done(gave = given)))
}
// Only theirs: they sign it over, so wait for it to arrive.
else -> Step()
}
}
/** Their acceptance carried the agreement with both signatures on it: apply it here too. */
private fun complete(a: ExchangeAgreement, theirs: List<Iou>): Step {
val mineSide = a.side(myOffer.firstOrNull()?.id ?: "")
?: throw IllegalStateException("agreement is not about what you put down")
if (mineSide.holder != me) throw IllegalStateException("agreement does not name you as the holder")
val signature = mineSide.signature ?: throw IllegalStateException("agreement is unsigned on your side")
if (!Keys.verify(
me,
a.signingBytes(),
signature
)
) throw IllegalStateException("acceptance of a proposal you did not make")
if (mineSide.ious.map { it.iouId }.toSet() != myOffer.map { it.id }.toSet())
throw IllegalStateException("agreement is not about what you put down")
val their = theirs.ifEmpty { theirOffer }
val theirSide =
a.other(mineSide.ious.first().iouId) ?: throw IllegalStateException("agreement has no other side")
val byId = their.associateBy { it.id }
if (byId.size != theirSide.ious.size || theirSide.ious.any { ref ->
byId[ref.iouId]?.headHash() != ref.headHash
}) throw IllegalStateException("acceptance does not match the OwUs sent with it")
val myNew = myOffer.map {
Ledger.applyExchange(
store.ious.value[it.id] ?: throw IllegalStateException("your OwU is gone"), a
)
}
val theirNew = their.map { Ledger.applyExchange(it, a) }
(myNew + theirNew).forEach { store.put(it) }
clearTable()
val done = Event.Done(gave = myNew, got = theirNew)
// What came back may be promises of mine: the swap redeemed them.
val home = closeIfHome(theirNew)
return Step(send = home.send, events = listOf(done) + home.events)
}
/** Their half-signed swap: it must be about what is on the table, and signed by them. */
private fun checkProposal(a: ExchangeAgreement) {
if (theirOffer.isEmpty()) throw IllegalStateException("a swap with nothing on their side")
if (myOffer.isEmpty()) throw IllegalStateException("a swap with nothing on your side")
if (a.left.holder != peerKey) throw IllegalStateException("proposal is not theirs")
if (a.right.holder != me) throw IllegalStateException("proposal is not addressed to you")
val sig = a.left.signature ?: throw IllegalStateException("proposal unsigned")
if (!Keys.verify(peerKey, a.signingBytes(), sig)) throw IllegalStateException("proposal signature invalid")
if (!sameBundle(
a.left,
theirOffer
) { it }
) throw IllegalStateException("proposal is not about what they put down")
if (!sameBundle(a.right, myOffer) {
store.ious.value[it.id] ?: throw IllegalStateException("your OwU is gone")
})
throw IllegalStateException("proposal is not about what you put down")
}
/** The side names exactly these ious, each at the head we have for it. */
private fun sameBundle(side: net.helcel.owu.ledger.ExchangeSide, ious: List<Iou>, current: (Iou) -> Iou): Boolean {
if (side.ious.size != ious.size) return false
return ious.all { iou -> side.ref(iou.id)?.headHash == current(iou).headHash() }
}
private fun given(m: PeerMessage.Give): Step {
if (m.ious.isEmpty()) throw IllegalStateException("a gift of nothing")
val expected = theirOffer.map { it.id }.toSet()
m.ious.forEach { iou ->
val s = active(iou)
val head = iou.head as? Block.Transfer ?: throw IllegalStateException("a gift with no transfer on it")
if (head.transferor != peerKey || head.transferee != me || s.holder != me)
throw IllegalStateException("that gift is not from them to you")
if (expected.isNotEmpty() && iou.id !in expected) throw IllegalStateException("a gift of something else")
keep(iou)
}
clearTable()
// Promises of mine handed back to me: redeeming, with nothing to decide.
val home = closeIfHome(m.ious)
val got = Event.Done(got = m.ious)
return Step(send = home.send, events = listOf(got) + home.events)
}
/** An OwU back with its maker is spent - they were the only one who could
* be asked. However it arrived, it closes itself and the closed chain
* goes back as a receipt. */
private fun closeIfHome(ious: List<Iou>): Step {
val send = mutableListOf<PeerMessage>()
val events = mutableListOf<Event>()
ious.forEach { iou ->
val current = store.ious.value[iou.id] ?: iou
val state = Verifier.verify(current).stateOrNull ?: return@forEach
if (state.status != Status.ACTIVE || state.debtor != me || state.holder != me) return@forEach
val closed = Ledger.redeem(current, signer)
store.put(closed)
send += PeerMessage.Redeemed(closed)
events += Event.Redeemed(closed)
}
return Step(send = send, events = events)
}
/** What both sides say yes to: every OwU pinned to its current head. Both
* compute the same string, so a yes crossing a change is refused. */
private fun deal(): String? {
if (myOffer.isEmpty() && theirOffer.isEmpty()) return null
val sides = (myOffer + theirOffer)
.map { mapOf("iou_id" to it.id, "head_hash" to it.headHash()) }
.sortedBy { it["iou_id"] }
return Hash.sha256Hex(Canonical.bytes(sides))
}
/** The table changed: both yeses are void. */
private fun forget() {
myAccepted = null
theirAccepted = null
theirProposal = null
myProposal = null
}
/** Wipes the table after something happened on it: what was minted has moved. */
private fun clearTable() {
minted.clear()
myOffer = emptyList()
theirOffer = emptyList()
theirConflict = false
forget()
}
// --- redeem ------------------------------------------------------------
/** The receipt for an OwU handed back: closed by the one who made it. */
private fun redeemed(m: PeerMessage.Redeemed): Step {
val s = state(m.iou)
if (s.status != Status.REDEEMED || s.debtor != peerKey)
throw IllegalStateException("that is not a promise of theirs, closed by them")
keep(m.iou)
return Step(events = listOf(Event.Redeemed(m.iou)))
}
// --- helpers -----------------------------------------------------------
/** OwUs I hold and could put on the table. */
fun held(): List<Iou> = store.ious.value.values.filter {
Verifier.verify(it).stateOrNull?.let { s -> s.holder == me && s.status == Status.ACTIVE } == true
}
private fun state(iou: Iou): IouState = when (val v = Verifier.verify(iou)) {
is Verdict.Valid -> v.state
is Verdict.Invalid -> throw LedgerException("invalid chain at block ${v.sequence}: ${v.reason}")
}
private fun active(iou: Iou): IouState = state(iou).also {
if (it.status != Status.ACTIVE) throw LedgerException("OwU is ${it.status}")
}
/** Stores a chain received as the outcome of a handshake; anything but a clean fit is an error. */
private fun keep(iou: Iou) {
when (val r = store.merge(iou)) {
Merge.Added, Merge.Extended, Merge.Unchanged -> {}
is Merge.Fork -> throw IllegalStateException("conflicts with the copy you already have")
is Merge.Invalid -> throw IllegalStateException(r.reason)
}
}
companion object {
fun authBytes(nonce: String, key: String): ByteArray = "owu-auth-v1\n$nonce\n$key".toByteArray(Charsets.UTF_8)
}
}
/** Who a message is from, by its signatures or, unsigned, by what it states.
* Null when it names nobody. */
fun PeerMessage.counterparty(): String? = when (this) {
is PeerMessage.Table -> key
is PeerMessage.Accept -> key
is PeerMessage.Give -> (ious.firstOrNull()?.head as? Block.Transfer)?.transferor
is PeerMessage.Redeemed -> Verifier.verify(iou).stateOrNull?.debtor
else -> null
}
@@ -0,0 +1,186 @@
package net.helcel.owu.peer
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import net.helcel.owu.ble.Ble
import net.helcel.owu.ble.BlePermissions
import net.helcel.owu.ble.Link
import net.helcel.owu.store.Repo
/**
* The app's presence on the air, all of it [Link]: an advertisement to be
* found by, a connection to talk over. Only while the app is resumed
* ([resume]/[pause]). No switch: an ask has to arrive wherever you are.
*
* While resumed, also follows adapter on/off.
*/
// The link holds the *application* context; stop() lets go of it.
@SuppressLint("StaticFieldLeak")
object PeerManager {
data class State(
val active: Boolean = false,
/** Sessions by beacon hex, in order of appearance. */
val peers: Map<String, PeerSession> = emptyMap(),
val error: String? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
/** Adapter state, independent of [State.active]. */
enum class Radio { UNSUPPORTED, NO_PERMISSION, OFF, TURNING_ON, ON }
private val _radio = MutableStateFlow(Radio.OFF)
val radio: StateFlow<Radio> = _radio
private val _outcomes = MutableSharedFlow<Outcome>(extraBufferCapacity = 8)
/** Tables that finished, wherever they did. A redeem is answered from a
* prompt with no trade screen in sight, so the news must travel. */
val outcomes: SharedFlow<Outcome> = _outcomes
private var scope: CoroutineScope? = null
private var link: Link? = null
private var table: PeerTable? = null
private var listeningOn: Context? = null
private val main = Handler(Looper.getMainLooper())
private val adapterState = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) {
BluetoothAdapter.STATE_ON -> start(context)
// Tear down early; the link dies with the adapter.
BluetoothAdapter.STATE_TURNING_OFF, BluetoothAdapter.STATE_OFF -> stop()
}
refresh(context)
}
}
/** A verified session with the device whose key is [publicKey], if it is around. */
fun readySession(publicKey: String): PeerSession? = table?.readySession(publicKey)
/** Resumed: follow the adapter and start. */
fun resume(context: Context) {
val ctx = context.applicationContext
if (listeningOn == null) {
ContextCompat.registerReceiver(
ctx, adapterState, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED),
ContextCompat.RECEIVER_NOT_EXPORTED, // system broadcasts still arrive
)
listeningOn = ctx
}
refresh(ctx)
start(ctx)
}
/** Paused: stop and stop listening. */
fun pause() {
stopListening()
stop()
}
private fun stopListening() {
runCatching { listeningOn?.unregisterReceiver(adapterState) }
listeningOn = null
}
private fun refresh(context: Context) {
val adapter = BlePermissions.adapter(context)
_radio.value = when {
adapter == null -> Radio.UNSUPPORTED
!BlePermissions.granted(context) -> Radio.NO_PERMISSION
else -> when (adapter.state) {
BluetoothAdapter.STATE_ON -> Radio.ON
BluetoothAdapter.STATE_TURNING_ON -> Radio.TURNING_ON
else -> Radio.OFF
}
}
}
fun start(context: Context) {
if (_state.value.active) return
val ctx = context.applicationContext
if (!BlePermissions.granted(ctx)) return fail("Bluetooth permission not granted")
val adapter = BlePermissions.adapter(ctx) ?: return fail("No Bluetooth on this device")
if (!adapter.isEnabled) return fail("Bluetooth is off")
val s = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val me = Ble.beacon(Repo.me)
lateinit var l: Link
l = Link(ctx, adapter, me, onBroken = { reason -> main.post { standDown(l, reason) } })
val myName = Repo.myName(ctx)
val t = PeerTable(
s, { PeerEngine(Repo.store, Repo.signer, myName) }, ::send,
onOutcome = { _outcomes.tryEmit(it) })
scope = s
link = l
table = t
_state.value = State(active = true)
s.launch {
l.start()?.let { reason ->
main.post { standDown(l, reason) }
return@launch
}
launch { t.peers.collect { peers -> _state.update { it.copy(peers = peers) } } }
launch { l.presence.collect { t.heard(it) } }
launch { l.messages.collect { t.deliver(it) } }
launch {
while (isActive) {
delay(2_000)
t.prune()
}
}
}
}
fun stop() {
scope?.cancel()
scope = null
link?.stop()
link = null
table?.clear()
table = null
_state.value = State()
}
/** Opens the connection to [beacon]. True once messages can flow. */
suspend fun select(beacon: ByteArray): Boolean = link?.connect(beacon) ?: false
/** Done with that peer: hangs up. */
fun release(beacon: ByteArray) {
link?.disconnect(beacon)
}
private suspend fun send(beacon: ByteArray, bytes: ByteArray, dial: Boolean): Boolean =
link?.send(beacon, bytes, dial) ?: false
/** Link failed: reset so the next resume/adapter change/tap retries. */
private fun standDown(l: Link, reason: String) {
if (link !== l) return
stop()
fail(reason)
}
private fun fail(reason: String) = _state.update { it.copy(error = reason) }
}
@@ -0,0 +1,101 @@
package net.helcel.owu.peer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.ExchangeAgreement
import net.helcel.owu.ledger.Iou
/**
* What two devices say over a link. No reconciliation: each message is one
* step of an action a user took, carrying the whole chain it is about so the
* receiver verifies it from genesis.
*/
@Serializable
sealed class PeerMessage {
// --- handshake -----------------------------------------------------------
/** First message from each side: who I am and a challenge for you. */
@Serializable
@SerialName("HELLO")
data class Hello(val identity: IdentityCard, val nonce: String) : PeerMessage()
/** Proof that I hold the key in my HELLO: a signature over your nonce and my key. */
@Serializable
@SerialName("AUTH")
data class Auth(val signature: String) : PeerMessage()
// --- trade ---------------------------------------------------------------
// Two sides, a bundle or nothing on each. Both accept the same table and
// it happens: EXCHANGE if both brought something, TRANSFER if one did.
// Issuing, giving and swapping are all this.
/** The two things one side can ask of the other, and so the two it can refuse. */
@Serializable
enum class Asked { INVITE, TABLE }
/**
* No, not just now. Clears the refused table on both sides, so nothing
* sits refused; it exists so the asker is told instead of waiting for ever.
*/
@Serializable
@SerialName("DECLINE")
data class Decline(val key: String, val to: Asked) : PeerMessage()
/**
* I have opened a table and would like you at it. No ledger state, no
* answer: without it, wanting to trade reaches nobody not already looking.
*/
@Serializable
@SerialName("INVITE")
data class Invite(val key: String) : PeerMessage()
/**
* What I put on the table: OwUs I hold, or nothing. [key] is who I am,
* so a scanned code knows its counterparty even with nothing on it.
*/
@Serializable
@SerialName("TABLE")
data class Table(val key: String, val ious: List<Iou> = emptyList()) : PeerMessage()
/**
* I accept the table as I see it: my [ious] against yours, [deal]
* identifying it. For a swap, [agreement] is half-signed going first,
* countersigned answering; complete, both sides apply it.
*/
@Serializable
@SerialName("ACCEPT")
data class Accept(
val key: String,
val deal: String,
val ious: List<Iou> = emptyList(),
val agreement: ExchangeAgreement? = null,
) : PeerMessage()
/** My side of an accepted table, signed over to you: a gift, and the last message of it. */
@Serializable
@SerialName("GIVE")
data class Give(val ious: List<Iou>) : PeerMessage()
// --- redeem --------------------------------------------------------------
// Nothing waits: an OwU handed back to whoever owes it comes home closed.
/** Debtor to holder: their OwU back, with my signature voiding it. The receipt. */
@Serializable
@SerialName("REDEEMED")
data class Redeemed(val iou: Iou) : PeerMessage()
companion object {
val json = Json {
classDiscriminator = "type"
encodeDefaults = true
explicitNulls = false
ignoreUnknownKeys = true
}
fun encode(m: PeerMessage): ByteArray = json.encodeToString(serializer(), m).toByteArray(Charsets.UTF_8)
fun decode(bytes: ByteArray): PeerMessage = json.decodeFromString(serializer(), bytes.toString(Charsets.UTF_8))
}
}
@@ -0,0 +1,250 @@
package net.helcel.owu.peer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.helcel.owu.helper.IdentityCard
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Verifier
import java.util.concurrent.atomic.AtomicInteger
/**
* One device as we know it: its [PeerEngine], the messages we owe it (sent in
* order, one at a time), and state for the screens. It exists while the peer
* is heard; the connection underneath is the [net.helcel.owu.ble.Link]'s.
*/
class PeerSession(
val beacon: ByteArray,
private val engine: PeerEngine,
/** Delivers one message; true once acked. With `dial`, connects first if need be. */
private val send: suspend (bytes: ByteArray, dial: Boolean) -> Boolean,
parent: CoroutineScope,
/** Called once for each table that finished, so something can say so out loud. */
private val onOutcome: (Outcome) -> Unit = {},
) {
enum class Phase { HANDSHAKE, READY, GONE }
data class State(
val phase: Phase = Phase.HANDSHAKE,
val peer: IdentityCard? = null,
val rssi: Int = 0,
val log: List<String> = emptyList(),
/** What the two of us have put on the table, and who has said yes. */
val table: PeerEngine.Table = PeerEngine.Table(),
/** What we are waiting on the peer for, if anything. */
val waiting: String? = null,
/** The last thing that did not reach them, so a screen can say so. */
val undelivered: String? = null,
/** They have asked us to the table and we have neither come nor declined. */
val invited: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job]))
private val mutex = Mutex()
/** Messages to send, each with the log line that belongs to it once it has arrived. */
private val outbox = Channel<Pair<PeerMessage, String?>>(Channel.UNLIMITED)
/** Messages queued and not yet on the air or acked. */
private val inFlight = AtomicInteger(0)
init {
scope.launch {
for ((m, line) in outbox) {
// A HELLO is said into the dark; anything else is a person's
// action and worth a connection. This is "redeem finds them".
if (send(PeerMessage.encode(m), m !is PeerMessage.Hello)) {
if (line != null) log(line)
} else if (m !is PeerMessage.Hello) {
// An unanswered HELLO is ordinary; we say it again presently.
// A user's action that did not arrive is not, so say so.
val what = line ?: m::class.simpleName.orEmpty()
_state.update {
it.copy(log = it.log + "Not delivered: $what", waiting = null, undelivered = what)
}
}
inFlight.decrementAndGet()
}
}
perform { engine.start() }
// Until verified, say hello again: the first may have gone out before
// the peer was listening.
scope.launch {
while (_state.value.phase == Phase.HANDSHAKE) {
delay(REINTRODUCE_MS)
if (_state.value.phase == Phase.HANDSHAKE && inFlight.get() == 0) perform { engine.reintroduce() }
}
}
}
/** A message from this peer, off the air. */
fun deliver(bytes: ByteArray) = perform { engine.onMessage(PeerMessage.decode(bytes)) }
fun heard(rssi: Int) = _state.update { it.copy(rssi = rssi) }
// --- user actions ------------------------------------------------------
/** My side of the table: ious I hold plus one minted per template. Empty takes it off. */
fun put(held: List<Iou> = emptyList(), mint: List<Metadata> = emptyList()) = perform { engine.put(held, mint) }
/** Mint one OwU from a template and put that on the table by itself. */
fun putNew(metadata: Metadata) = perform { engine.putNew(metadata) }
/** Say yes to the table as it stands. */
fun accept() = perform(waiting = "them to accept") { engine.accept() }
/** Ask them to the table just opened; until this they had no way of knowing. */
fun invite() = perform { engine.invite() }
/** Their invitation has been answered, one way or the other. */
fun inviteAnswered() = _state.update { it.copy(invited = false) }
/** No, not just now - and say so, rather than leaving them waiting. */
fun decline(to: PeerMessage.Asked) = perform { engine.decline(to) }
/** Put [ious] down and say yes in one move, under one lock so the yes
* cannot overtake the ious it is about. */
fun offer(ious: List<Iou>) = perform(waiting = "them to accept") {
val down = engine.put(held = ious)
val yes = engine.accept()
// One message, not two: the acceptance carries its own ious, so a
// TABLE first only adds something that can go missing. Its events
// still fire, so this side's screens see what was put down.
PeerEngine.Step(send = yes.send, events = down.events + yes.events)
}
/** Give up on an answer that is not coming; whatever was pending stays valid if it does. */
fun stopWaiting() = _state.update { it.copy(waiting = null) }
/** The peer has not been heard for a while. */
fun gone() {
// Anything minted for a table that never happened goes with them.
runCatching { engine.abandon() }
scope.cancel()
outbox.close()
_state.update { it.copy(phase = Phase.GONE, waiting = null) }
}
// --- plumbing ----------------------------------------------------------
/** Runs one engine step off the calling thread. [line] is logged once the
* last message is acked, so "Sent" means sent. */
private fun perform(line: String? = null, waiting: String? = null, block: () -> PeerEngine.Step) {
scope.launch {
try {
mutex.withLock {
// A fresh attempt: whatever failed last time is history.
_state.update { it.copy(undelivered = null) }
val step = block()
step.send.forEachIndexed { i, m ->
inFlight.incrementAndGet()
outbox.send(m to line.takeIf { i == step.send.lastIndex })
}
for (e in step.events) handle(e)
announce(step.events)
if (step.send.isEmpty() && line != null) log(line)
// Nothing to wait for if the step already finished it.
if (waiting != null && step.events.none { it is PeerEngine.Event.Done }) {
_state.update { it.copy(waiting = waiting) }
}
}
} catch (e: Exception) {
log("Error: ${e.message}")
}
}
}
private fun handle(e: PeerEngine.Event) {
when (e) {
// No log line: the tick beside their key says it where it matters.
is PeerEngine.Event.PeerIdentified -> _state.update {
it.copy(phase = Phase.READY, peer = e.card)
}
PeerEngine.Event.Restarted -> _state.update {
State(
phase = Phase.HANDSHAKE,
peer = it.peer,
rssi = it.rssi,
log = it.log + "Peer came back; verifying again"
)
}
is PeerEngine.Event.Tabled -> _state.update { it.copy(table = e.table) }
is PeerEngine.Event.Done -> {
_state.update { it.copy(table = PeerEngine.Table()) }
val gave = summarise(e.gave)
val got = summarise(e.got)
answered(
when {
gave != null && got != null -> "Traded $gave for $got"
gave != null -> "Gave $gave"
got != null -> "Got $got"
else -> "Nothing changed hands"
}
)
}
is PeerEngine.Event.Redeemed -> {
// A redemption ends the table it was on, on both sides.
_state.update { it.copy(table = PeerEngine.Table()) }
answered("${e.iou.metadata.title} redeemed")
}
is PeerEngine.Event.Invited -> _state.update { it.copy(invited = true) }
// Nothing is being waited for any more; their answer was no.
is PeerEngine.Event.Declined -> _state.update { it.copy(waiting = null) }
is PeerEngine.Event.Failed -> log("Error: ${e.reason}")
}
}
/**
* One announcement per step, or none. An OwU going home is not announced
* as given but when its closed receipt returns, which is when it happened;
* on their side gift and receipt are one step. Either way, named once.
*/
private fun announce(events: List<PeerEngine.Event>) {
val done = events.filterIsInstance<PeerEngine.Event.Done>()
val redeemed = events.filterIsInstance<PeerEngine.Event.Redeemed>().map { it.iou }
val closed = redeemed.map { it.id }.toSet()
val peer = _state.value.peer?.key
val outcome = Outcome(
peer = peer,
gave = done.flatMap { it.gave }.filter { it.id !in closed && !goesHome(it, peer) },
got = done.flatMap { it.got }.filter { it.id !in closed },
redeemed = redeemed,
declined = events.filterIsInstance<PeerEngine.Event.Declined>().firstOrNull()?.to,
)
if (!outcome.isEmpty) onOutcome(outcome)
}
/** A promise of theirs, on its way back to them: a redemption in progress. */
private fun goesHome(iou: Iou, peer: String?): Boolean =
peer != null && Verifier.verify(iou).stateOrNull?.debtor == peer
private fun log(line: String) = _state.update { it.copy(log = it.log + line) }
private fun answered(line: String) = _state.update { it.copy(log = it.log + line, waiting = null) }
companion object {
private const val REINTRODUCE_MS = 6_000L
}
}
/** A bundle in one line: "3 × 1 Beer, 1 Heavy Hug", not the title three times. */
private fun summarise(ious: List<net.helcel.owu.ledger.Iou>): String? {
if (ious.isEmpty()) return null
return ious.groupingBy { it.metadata.title }.eachCount().entries
.joinToString(", ") { (title, n) -> if (n > 1) "$n × $title" else title }
}
@@ -0,0 +1,101 @@
package net.helcel.owu.peer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import net.helcel.owu.ble.Ble
import net.helcel.owu.ble.Heard
import net.helcel.owu.crypto.Hash
/**
* The devices around us, one [PeerSession] each, keyed by beacon. Nothing
* Android in it. A session starts on the first presence heard, or on a HELLO,
* which names its sender's key.
*/
class PeerTable(
private val scope: CoroutineScope,
private val newEngine: () -> PeerEngine,
/** Delivers bytes to the device with this beacon; true once acked. With `dial`, connects first if need be. */
private val send: suspend (beacon: ByteArray, bytes: ByteArray, dial: Boolean) -> Boolean,
private val clock: () -> Long = System::currentTimeMillis,
/** Passed to every session: what a finished table amounted to. */
private val onOutcome: (Outcome) -> Unit = {},
) {
private val _peers = MutableStateFlow<Map<String, PeerSession>>(emptyMap())
/** Sessions by beacon hex, in order of appearance. */
val peers: StateFlow<Map<String, PeerSession>> = _peers
private val lastHeard = mutableMapOf<String, Long>()
/** Two sessions for one peer means two handshakes, which each side reads
* as the other restarting. So looking and creating are one step. */
private val lock = Any()
/** A verified session with the device whose key is [publicKey], if it is around. */
fun readySession(publicKey: String): PeerSession? =
_peers.value[Ble.beaconHex(publicKey)]?.takeIf { it.state.value.phase == PeerSession.Phase.READY }
fun heard(seen: Heard.Presence): PeerSession = synchronized(lock) {
val key = Hash.hex(seen.beacon)
lastHeard[key] = clock()
val existing = _peers.value[key]
if (existing != null && existing.state.value.phase != PeerSession.Phase.GONE) {
existing.heard(seen.rssi)
return existing
}
val beacon = seen.beacon
val session = PeerSession(beacon, newEngine(), { bytes, dial -> send(beacon, bytes, dial) }, scope, onOutcome)
session.heard(seen.rssi)
_peers.update { it + (key to session) }
return session
}
/** Hands a message to its sender's session, making one if their HELLO introduces them. */
fun deliver(inbound: Heard.Message) = synchronized(lock) {
(sessionFor(inbound.from) ?: adopt(inbound))?.deliver(inbound.bytes)
}
/** From a device not yet heard: a HELLO names its key, which is enough. */
private fun adopt(inbound: Heard.Message): PeerSession? {
val hello = runCatching { PeerMessage.decode(inbound.bytes) }.getOrNull() as? PeerMessage.Hello ?: return null
val beacon = runCatching { Ble.beacon(hello.identity.key) }.getOrNull() ?: return null
if (!beacon.contentEquals(inbound.from)) return null
return heard(Heard.Presence(beacon, 0))
}
private fun sessionFor(beacon: ByteArray): PeerSession? =
_peers.value.values.firstOrNull { it.beacon.contentEquals(beacon) }
/**
* Drops sessions unheard for a while. Advertising is the *only* evidence
* of presence: an open channel is not, since a phone that sleeps or dies
* often leaves one behind with no disconnect ever arriving, and a session
* propped up by that never goes. Tried it; worse than the wait it saved.
*/
fun prune() {
synchronized(lock) {
val now = clock()
val stale = _peers.value.filter { (k, p) ->
p.state.value.phase == PeerSession.Phase.GONE || now - (lastHeard[k] ?: 0L) > GONE_MS
}
if (stale.isEmpty()) return
stale.values.forEach { it.gone() }
_peers.update { it - stale.keys }
}
}
/** Ends every session. */
fun clear() = synchronized(lock) {
_peers.value.values.forEach { it.gone() }
lastHeard.clear()
_peers.value = emptyMap()
}
companion object {
/** How long somebody stays "here" unheard. A live peer advertises many
* times a second, so this is many misses, not a close call. Short,
* because the dot and the trade button act on it. */
const val GONE_MS = 12_000L
}
}
@@ -0,0 +1,114 @@
package net.helcel.owu.store
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouJson
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/** What went wrong opening a backup, in terms the screen can repeat. */
class BackupException(message: String) : Exception(message)
/**
* Everything this phone is in one file: identity, OwUs, templates, contacts.
*
* Encrypted under a passphrase, because it holds a private key - whoever opens
* it can *be* you. AES-GCM under a PBKDF2 key, parameters in the clear so an
* older file still opens when they change.
*/
object Backup {
@Serializable
data class Contents(
/** The identity's private half, PKCS#8 DER, base64. */
@SerialName("private_key") val privateKey: String,
/** Its public half, X.509 DER, base64 - kept so the pair can be checked. */
@SerialName("public_key") val publicKey: String,
val name: String = "",
val ious: List<Iou> = emptyList(),
val templates: List<Template> = emptyList(),
val contacts: List<Contact> = emptyList(),
)
@Serializable
private data class Envelope(
val v: Int = VERSION,
val app: String = "owu",
val kdf: String = "PBKDF2WithHmacSHA256",
val rounds: Int = ROUNDS,
val salt: String,
val nonce: String,
val data: String,
)
const val VERSION = 1
private const val ROUNDS = 210_000
private const val KEY_BITS = 256
private const val TAG_BITS = 128
private const val SALT = 16
private const val NONCE = 12
/** The bytes to write to the file the user chose. */
fun seal(contents: Contents, passphrase: CharArray): ByteArray {
val random = SecureRandom()
val salt = ByteArray(SALT).also(random::nextBytes)
val nonce = ByteArray(NONCE).also(random::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, key(passphrase, salt, ROUNDS), GCMParameterSpec(TAG_BITS, nonce))
}
val plain = IouJson.json.encodeToString(Contents.serializer(), contents).toByteArray(Charsets.UTF_8)
val envelope = Envelope(
salt = base64(salt),
nonce = base64(nonce),
data = base64(cipher.doFinal(plain)),
)
return IouJson.json.encodeToString(Envelope.serializer(), envelope).toByteArray(Charsets.UTF_8)
}
/** What is in a backup file, or [BackupException] saying why not. */
fun open(bytes: ByteArray, passphrase: CharArray): Contents {
val envelope = try {
IouJson.json.decodeFromString(Envelope.serializer(), bytes.toString(Charsets.UTF_8))
} catch (e: Exception) {
throw BackupException("that is not an OwU backup")
}
if (envelope.app != "owu" || envelope.v > VERSION) throw BackupException("that backup is from a newer OwU")
val plain = try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(
Cipher.DECRYPT_MODE,
key(passphrase, unbase64(envelope.salt), envelope.rounds),
GCMParameterSpec(TAG_BITS, unbase64(envelope.nonce)),
)
}
cipher.doFinal(unbase64(envelope.data))
} catch (e: AEADBadTagException) {
// One message for both: a wrong passphrase and a changed byte are
// the same event to AES-GCM, and telling them apart helps nobody.
throw BackupException("wrong passphrase, or the file has been damaged")
} catch (e: Exception) {
throw BackupException("that backup could not be read")
}
return try {
IouJson.json.decodeFromString(Contents.serializer(), plain.toString(Charsets.UTF_8))
} catch (e: Exception) {
throw BackupException("that backup could not be read")
}
}
private fun key(passphrase: CharArray, salt: ByteArray, rounds: Int) = SecretKeySpec(
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(PBEKeySpec(passphrase, salt, rounds, KEY_BITS)).encoded,
"AES",
)
private fun base64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)
private fun unbase64(text: String): ByteArray = Base64.getDecoder().decode(text)
}
@@ -0,0 +1,193 @@
package net.helcel.owu.store
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import net.helcel.owu.ledger.Iou
import net.helcel.owu.ledger.IouJson
import net.helcel.owu.ledger.Metadata
import net.helcel.owu.ledger.Verdict
import net.helcel.owu.ledger.Verifier
import java.io.File
/** A promise kept ready to make: no chain, no signature. Handing one over
* mints a fresh OwU - new id, signed there and then - so one "1 Beer" here
* becomes as many beers as you hand out. Stays until deleted. */
@Serializable
data class Template(
val id: String,
val metadata: Metadata,
@SerialName("created_at") val createdAt: Long,
)
/** Somebody you have met and named. The name is yours; the key is what makes
* it worth anything. With it, an OwU arriving through a third party still
* reads as "owed by Alice": the signature proves the key, this says whose. */
@Serializable
data class Contact(
@SerialName("pub_key") val publicKey: String,
val name: String,
)
/** A key that introduced itself on the air, with the name it gave. Not a
* [Contact]: nobody vouched for it, and it only makes a screen readable. */
@Serializable
data class Met(
@SerialName("pub_key") val publicKey: String,
val name: String,
)
/** What happened when a chain arrived from outside. */
sealed class Merge {
object Added : Merge()
object Extended : Merge()
object Unchanged : Merge()
/** Same OwU, different history: neither side is a prefix of the other. */
data class Fork(val existing: Iou) : Merge()
data class Invalid(val reason: String) : Merge()
}
/** Everything the app keeps: a JSON file per OwU and per template, plus the
* address book. Small enough to live in memory, written straight to disk. */
class IouStore(root: File) {
private val iouDir = File(root, "ious").apply { mkdirs() }
private val templateDir = File(root, "templates").apply { mkdirs() }
private val contactsFile = File(root, "contacts.json")
private val metFile = File(root, "met.json")
private val _ious = MutableStateFlow(loadDir(iouDir, Iou.serializer()).associateBy { it.id })
val ious: StateFlow<Map<String, Iou>> = _ious
private val _templates = MutableStateFlow(loadDir(templateDir, Template.serializer()).associateBy { it.id })
val templates: StateFlow<Map<String, Template>> = _templates
private val _contacts = MutableStateFlow(loadContacts())
val contacts: StateFlow<List<Contact>> = _contacts
private val _met = MutableStateFlow(loadList(metFile, Met.serializer()))
/** Names keys have given for themselves, for reading screens by. */
val met: StateFlow<List<Met>> = _met
// --- OwUs -------------------------------------------------------------
/** Stores a chain we made ourselves, which [net.helcel.owu.ledger.Ledger] has already verified. */
fun put(iou: Iou) {
write(File(iouDir, "${iou.id}.json"), IouJson.encode(iou))
_ious.update { it + (iou.id to iou) }
}
fun remove(id: String) {
File(iouDir, "$id.json").delete()
_ious.update { it - id }
}
/** Takes a chain from a peer. Only a valid extension of what we have, or
* one new to us, is stored; a fork is reported, not resolved, since only
* the debtor can decide it. */
fun merge(incoming: Iou): Merge = compare(incoming).also { verdict ->
when (verdict) {
Merge.Added, Merge.Extended -> put(incoming)
else -> {}
}
}
/** What [merge] would make of [incoming], without keeping it: an OwU looked
* over before anyone agrees. A [Merge.Fork] means it and the copy you hold
* each carry signatures the other does not - someone signed twice. */
fun compare(incoming: Iou): Merge {
val verdict = Verifier.verify(incoming)
if (verdict is Verdict.Invalid) return Merge.Invalid("block ${verdict.sequence}: ${verdict.reason}")
val existing = _ious.value[incoming.id] ?: return Merge.Added
val common = minOf(existing.chain.size, incoming.chain.size)
if (existing.metadata != incoming.metadata || existing.chain.take(common) != incoming.chain.take(common)) {
return Merge.Fork(existing)
}
if (incoming.chain.size <= existing.chain.size) return Merge.Unchanged
return Merge.Extended
}
// --- templates ----------------------------------------------------------
fun putTemplate(template: Template) {
write(File(templateDir, "${template.id}.json"), json.encodeToString(Template.serializer(), template))
_templates.update { it + (template.id to template) }
}
fun removeTemplate(id: String) {
File(templateDir, "$id.json").delete()
_templates.update { it - id }
}
// --- contacts ----------------------------------------------------------
fun nameFor(publicKey: String): String? = _contacts.value.firstOrNull { it.publicKey == publicKey }?.name
/** The name a key gave for itself, if we have heard it introduce itself. */
fun metName(publicKey: String): String? = _met.value.firstOrNull { it.publicKey == publicKey }?.name
/** Remembers what a key calls itself. Never touches what you have named it. */
fun putMet(publicKey: String, name: String) {
val clean = name.trim()
if (clean.isEmpty() || metName(publicKey) == clean) return
val list = _met.value.filter { it.publicKey != publicKey } + Met(publicKey, clean)
write(metFile, json.encodeToString(ListSerializer(Met.serializer()), list))
_met.value = list
}
fun putContact(contact: Contact) = saveContacts(
_contacts.value.filter { it.publicKey != contact.publicKey } + contact
)
fun removeContact(publicKey: String) = saveContacts(_contacts.value.filter { it.publicKey != publicKey })
private fun saveContacts(list: List<Contact>) {
write(contactsFile, json.encodeToString(ListSerializer(Contact.serializer()), list))
_contacts.value = list
}
private fun loadContacts(): List<Contact> = loadList(contactsFile, Contact.serializer())
private fun <T> loadList(file: File, serializer: KSerializer<T>): List<T> =
if (!file.exists()) emptyList() else try {
json.decodeFromString(ListSerializer(serializer), file.readText())
} catch (e: Exception) {
Log.w(TAG, "unreadable ${file.name}", e)
emptyList()
}
// --- files -------------------------------------------------------------
private fun <T> loadDir(dir: File, serializer: KSerializer<T>): List<T> =
(dir.listFiles { f -> f.extension == "json" } ?: emptyArray()).mapNotNull { f ->
try {
json.decodeFromString(serializer, f.readText())
} catch (e: Exception) {
// Leave it in place: a corrupt file is worth more to a
// developer than a clean directory is to the app.
Log.w(TAG, "skipping unreadable ${f.name}", e)
null
}
}
/** Write-then-rename, so a crash mid-write cannot leave a half file behind. */
private fun write(target: File, text: String) {
val tmp = File(target.parentFile, "${target.name}.tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
companion object {
private const val TAG = "IouStore"
private val json get() = IouJson.json
}
}
@@ -0,0 +1,67 @@
package net.helcel.owu.store
import android.content.Context
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import net.helcel.owu.BuildConfig
import net.helcel.owu.crypto.Keys
import net.helcel.owu.crypto.Identity
import net.helcel.owu.demo.Demo
import net.helcel.owu.helper.defaultPreferences
/**
* The app's one identity and one store, opened once per process. [init] does
* the Keystore work and so belongs off the main thread.
*/
object Repo {
lateinit var store: IouStore
private set
lateinit var signer: Identity
private set
private val _ready = MutableStateFlow(false)
val ready: StateFlow<Boolean> = _ready
val me: String get() = signer.publicKey
@Synchronized
fun init(context: Context) {
if (_ready.value) return
store = IouStore(context.filesDir)
signer = Identity.load(context)
if (BuildConfig.DEBUG) {
Log.i("owu", "identity ${signer.publicKey}")
// A debug install starts with something to look at, once. R8 drops
// this and Demo with it from a release.
if (Demo.wanted(store)) Demo.fill(store, signer)
}
_ready.value = true
}
/** How a key is shown: you, your name for it, its own, or its fingerprint. */
fun nameOf(publicKey: String): String = when {
publicKey == me -> "You"
else -> store.nameFor(publicKey) ?: store.metName(publicKey) ?: Keys.fingerprint(publicKey)
}
/** Whether you have named this key. An OwU owed by a contact reads as that
* person's promise however it reached you; one owed by a key you never met
* is a stranger's, whatever the person handing it over calls them. */
fun knows(publicKey: String): Boolean = publicKey == me || store.nameFor(publicKey) != null
/** Becomes the identity from a backup. Everything signed from here on is that one. */
@Synchronized
fun adopt(identity: Identity) {
signer = identity
}
fun myName(context: Context): String = defaultPreferences(context).getString(KEY_NAME, "") ?: ""
fun setMyName(context: Context, name: String) {
defaultPreferences(context).edit { putString(KEY_NAME, name) }
}
private const val KEY_NAME = "my_name"
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Nothing but colour: the launcher gives the icon its shape. -->
<path
android:fillColor="@color/blue"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
The face the name is: O w U - one eye, a mouth, the other eye. Both eyes
stand on the same baseline (y = 62) and reach the same height (y = 42);
the mouth is centred between them on the same band.
-->
<path
android:pathData="M32,52m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white" />
<path
android:pathData="M47,50l3.5,10l3.5,-8l3.5,8l3.5,-10"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M68,44v8a8,8 0,0 0,16 0v-8"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- The same face, for themed icons: the system supplies the colour. -->
<path
android:pathData="M32,52m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white" />
<path
android:pathData="M47,50l3.5,10l3.5,-8l3.5,8l3.5,-10"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M68,44v8a8,8 0,0 0,16 0v-8"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Owu" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Only seen for the instant before the first frame. -->
<item name="android:windowBackground">@color/darkgray</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="darkgray">#FF0C1D2E</color>
<color name="white">#FFF0F3F7</color>
<color name="blue">#0d5cab</color>
</resources>
+178
View File
@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="action_settings">Settings</string>
<string name="action_new">New OwU</string>
<string name="bluetooth_on">Bluetooth on: nearby phones can reach you</string>
<string name="bluetooth_starting">Bluetooth starting…</string>
<string name="bluetooth_off">Bluetooth off: tap to turn it on</string>
<string name="bluetooth_unsupported">This device has no Bluetooth</string>
<string name="action_save">Save</string>
<string name="action_save_and_offer">Save and put it on the table</string>
<string name="action_redeem">Redeem</string>
<string name="redeem_ask">%1$s wants to redeem</string>
<string name="redeem_later">Not now</string>
<string name="redeem_waiting">Waiting for %1$s to accept…</string>
<string name="redeem_unreachable">Could not reach %1$s. They may be away; the request is still out.</string>
<string name="redeem_stop">Stop asking</string>
<string name="redeem_asked">Asked %1$s to redeem it</string>
<string name="redeem_looking">Looking for %1$s…</string>
<string name="done_redeemed">%1$s redeemed</string>
<string name="done_traded">Traded %1$s for %2$s</string>
<string name="done_gave">Gave %1$s</string>
<string name="done_got">Got %1$s</string>
<string name="done_with">with %1$s</string>
<string name="done_declined_trade">Not up for a trade just now</string>
<string name="done_declined_ask">Not redeeming it just now</string>
<string name="action_accept">Accept</string>
<string name="action_confirm">Confirm</string>
<string name="action_copy_key">Copy key</string>
<string name="template_new">New template</string>
<string name="edit_template">Edit template</string>
<string name="key_theme">App theme</string>
<string name="system">System</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="about">About</string>
<string name="pref_category_notes">OwUs</string>
<string name="pref_category_data">Backup</string>
<string name="identity_title">Profile</string>
<string name="field_your_name">Your name</string>
<string name="contacts">Contacts</string>
<string name="no_contacts">No contacts yet. Scan someone\'s QR code to add them.</string>
<string name="contact_add">Add contact</string>
<string name="contact_name_this">Name this person</string>
<string name="contact_rename">Rename</string>
<string name="contact_name_hint">Their key is proven; the name is yours to choose. OwUs they owe will read as this name, even when they reach you through someone else.</string>
<string name="contact_share">Share as QR</string>
<string name="contact_away">Not around</string>
<string name="contact_here">Here now</string>
<string name="contact_trade">Trade</string>
<string name="trade_ask">%1$s wants to trade</string>
<string name="trade_accept">Open the table</string>
<string name="contact_add_manually">Enter key</string>
<string name="contact_scan">Scan QR</string>
<string name="contact_from_clipboard">From clipboard</string>
<string name="contact_unreadable">That is not an OwU identity.</string>
<string name="contact_is_you">That is your own key.</string>
<string name="scan_prompt">Scan an OwU identity code</string>
<string name="field_key">Public key</string>
<string name="copied">Copied</string>
<string name="list_empty">No OwUs yet.\nWhat you hold and what you have handed over shows up here. Tap + to write a promise you are ready to make.</string>
<string name="section_templates">Templates</string>
<string name="section_other">Given away</string>
<string name="redeemed_title">Redeemed</string>
<string name="owed_title">Owed</string>
<string name="templates_empty">No templates yet. Write a promise you expect to make more than once - a beer, a lift - and it waits here until you hand one out.</string>
<string name="owed_empty">You owe nobody anything. Promises of yours that somebody else is holding show up here.</string>
<string name="redeemed_empty">Nothing has been redeemed yet. An OwU that comes home and is closed ends up here.</string>
<string name="from_x">from %1$s</string>
<string name="to_x">to %1$s</string>
<string name="owed_by_x">owed by %1$s</string>
<string name="in_your_contacts">key you hold</string>
<string name="own_promise">their own promise</string>
<string name="own_promise_yours">your own promise</string>
<string name="own_promise_mine">your own promise, coming back</string>
<string name="yours_to_give">not given yet</string>
<string name="closed_by_x">closed by %1$s</string>
<string name="back_with_x">back with %1$s</string>
<string name="untitled">Untitled</string>
<string name="template_ready">Template</string>
<plurals name="template_given">
<item quantity="one">given once</item>
<item quantity="other">given %1$d times</item>
</plurals>
<string name="status_active">Active</string>
<string name="status_redeemed">Redeemed</string>
<string name="field_title">What is owed</string>
<string name="field_description">Details (optional)</string>
<string name="field_description_hint">Terms, the occasion, anything worth writing down</string>
<string name="field_status">Status</string>
<string name="field_geoloc">Place</string>
<string name="field_geoloc_desc">Where it is meant to be redeemed. Never a bar.</string>
<string name="field_geo_label">Place name</string>
<string name="field_lat">Latitude</string>
<string name="field_lon">Longitude</string>
<string name="field_radius">Radius (m)</string>
<string name="geo_use_current">Use my location</string>
<string name="geo_locating">Locating…</string>
<string name="geo_unavailable">Could not get your location.</string>
<string name="geo_permission_denied">Location permission is needed for that.</string>
<string name="field_window">Valid</string>
<string name="field_window_desc">When it is meant to be redeemed. Never a bar.</string>
<string name="field_not_before">Not before</string>
<string name="field_not_after">Not after</string>
<string name="window_from">from %1$s</string>
<string name="window_until">until %1$s</string>
<string name="window_expired">expired</string>
<string name="issue_bad_window">The window closes before it opens.</string>
<string name="role_debtor">Owed by</string>
<string name="role_holder">Held by</string>
<string name="issue_need_title">Say what is owed.</string>
<string name="issue_bad_geo">Check the place coordinates and radius.</string>
<string name="history">History</string>
<string name="hist_issued">%1$s wrote it</string>
<string name="hist_transferred">%1$s handed it to %2$s</string>
<string name="hist_closed">%1$s closed it</string>
<string name="hist_exchanged">%1$s exchanged it with %2$s</string>
<string name="hist_redeemed">%1$s redeemed it</string>
<string name="invalid_chain">This OwU fails verification at block %1$d: %2$s</string>
<string name="delete_iou_confirm">Remove this OwU from this device? Others who hold a copy are not affected.</string>
<string name="backup_export">Export</string>
<string name="backup_restore">Restore</string>
<string name="backup_passphrase">Passphrase</string>
<string name="backup_passphrase_again">Passphrase again</string>
<string name="backup_passphrase_hint">This file is your identity. Nothing opens it without the passphrase.</string>
<string name="backup_passphrase_ask">The passphrase this backup was saved with.</string>
<string name="backup_written">Backup saved.</string>
<string name="backup_restored">Restored.</string>
<string name="backup_failed">That did not work.</string>
<string name="backup_identity_title">Become this identity?</string>
<string name="backup_identity_text">Your OwUs are back. The backup also holds the identity %1$s. Taking it on replaces this phone\u2019s own: promises made under that one could no longer be closed here.</string>
<string name="backup_identity_take">Become it</string>
<string name="backup_identity_keep">Keep mine</string>
<string name="backup_identity_taken">This phone is that identity now.</string>
<string name="app_is_foss">iOweU is free and open source software, licensed under the GNU General Public License (version 3 or later)</string>
<string name="app_repo_uri" translatable="false">https://github.com/helcel-net/iOweU</string>
<string name="app_repo">Project repository: %1$s\nFeel free to report issues or contribute.</string>
<string name="peer_unknown">Someone</string>
<string name="peer_verifying">Verifying identity…</string>
<string name="peer_verified">Verified</string>
<string name="peer_waiting">Waiting for %1$s…</string>
<string name="peer_stop_waiting">Stop waiting</string>
<string name="peer_gone">Out of range</string>
<string name="table_title">On the table</string>
<string name="table_you">You</string>
<string name="table_nothing">nothing</string>
<string name="table_put">Add</string>
<string name="table_change">Change</string>
<string name="table_pick_mine">Put on the table</string>
<plurals name="table_put_n">
<item quantity="one">Put %1$d down</item>
<item quantity="other">Put %1$d down</item>
</plurals>
<string name="more">One more</string>
<string name="fewer">One fewer</string>
<string name="table_take_off">Take mine off</string>
<string name="table_new_promise">Write a new promise…</string>
<string name="table_from_presets">From a template</string>
<string name="table_from_held">Or pass on an OwU you hold</string>
<string name="table_conflict">This OwU contradicts the copy you already hold: it has been signed over twice. Do not accept it.</string>
<string name="peer_name_clash">Calls itself %1$s, but this is not the key you know %1$s by.</string>
<string name="table_waiting_for">Waiting for %1$s to confirm.</string>
<string name="issue_onto_table">This promise goes onto the table with %1$s.</string>
<string name="peer_connecting">Connecting…</string>
<string name="peer_no_link">Could not connect. Move closer and try again.</string>
<string name="name">Name</string>
<string name="delete">Delete</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
</resources>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The bars are transparent and the app paints behind them, so the strip
above the top bar is the top bar's own colour whatever the theme makes
that. Naming a colour here cannot work: the app bar is Material You's
dynamic primary, which is chosen on the device from the user's
wallpaper, and no value written in this file will ever match it.
(From API 35 the platform ignores these colours anyway.)
-->
<style name="Theme.Owu" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Only seen for the instant before the first frame. -->
<item name="android:windowBackground">@color/white</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>