openvibers/openvibe.link Commits
Link on the phone: an Android app on the Node's own Go core (#131)
Changes
nnew file mode 100644@@@ -0,0 +1,132 @@++name: android++++on:++ push:++ branches: [main]++ tags: ["v*"]++ pull_request:++++permissions:++ contents: read++++jobs:++ apk:++ name: Go core, unit tests and the APK++ runs-on: ubuntu-latest++ permissions:++ contents: write # the APK is attached to the release on a tag++ steps:++ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2++ - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0++ with:++ distribution: temurin++ java-version: "17"++ - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0++ with:++ go-version-file: go.mod++ - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0++ with:++ node-version: "20"++ - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0++ with:++ gradle-version: "8.11.1"++++ - name: Android NDK (gomobile needs one)++ run: |++ ndk=27.0.12077973++ yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ndk" >/dev/null++ echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$ndk" >> "$GITHUB_ENV"++++ - name: Bind the Go core (gomobile, pinned)++ run: sh android/scripts/build-core.sh++++ - name: The generated design files are current++ run: sh android/scripts/gen-design.sh --check++++ - name: Unit tests++ run: gradle -p android :app:testDebugUnitTest++++ - name: Debug APK++ run: gradle -p android :app:assembleDebug++++ # A release APK is signed only when the runner has a key. The key is a repo secret; when it is absent the++ # step says so and is skipped, and no key is generated here.++ - name: The release key++ if: startsWith(github.ref, 'refs/tags/')++ env:++ KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}++ STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}++ KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}++ KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}++ run: |++ if [ -z "$KEYSTORE" ]; then++ echo "::warning::ANDROID_KEYSTORE_BASE64 is not set for this repository: the signed release APK is skipped, and no key is invented."++ echo "LINK_SIGNED=0" >> "$GITHUB_ENV"++ exit 0++ fi++ printf '%s' "$KEYSTORE" | base64 -d > android/release.keystore++ {++ echo "storeFile=../release.keystore"++ echo "storePassword=$STORE_PASSWORD"++ echo "keyAlias=$KEY_ALIAS"++ echo "keyPassword=$KEY_PASSWORD"++ } > android/keystore.properties++ chmod 600 android/release.keystore android/keystore.properties++ echo "LINK_SIGNED=1" >> "$GITHUB_ENV"++++ - name: Release APK++ if: startsWith(github.ref, 'refs/tags/') && env.LINK_SIGNED == '1'++ run: gradle -p android :app:assembleRelease++++ - name: APK sizes++ run: |++ for f in android/app/build/outputs/apk/debug/*.apk android/app/build/outputs/apk/release/*.apk; do++ [ -f "$f" ] || continue++ size=$(du -m "$f" | cut -f1)++ echo "| $f | ${size} MiB |" >> "$GITHUB_STEP_SUMMARY"++ echo "$f: ${size} MiB"++ done++++ - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3++ with:++ name: openvibe-link-debug++ path: android/app/build/outputs/apk/debug/*.apk++ if-no-files-found: error++++ - name: Attach the APK to the release++ if: startsWith(github.ref, 'refs/tags/') && env.LINK_SIGNED == '1'++ env:++ GH_TOKEN: ${{ github.token }}++ run: |++ gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "OpenVibe Link for Android."++ gh release upload "$GITHUB_REF_NAME" "android/app/build/outputs/apk/release/app-release.apk#openvibe-link-$GITHUB_REF_NAME.apk" --clobber++++ screens:++ name: emulator screenshots (not blocking)++ needs: apk++ runs-on: ubuntu-latest++ continue-on-error: true++ steps:++ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2++ - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8++ with:++ name: openvibe-link-debug++ path: android/app/build/outputs/apk/debug++ - name: Let the runner use the emulator's KVM++ run: |++ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules++ sudo udevadm control --reload-rules++ sudo udevadm trigger --name-match=kvm++ - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0++ with:++ api-level: 34++ arch: x86_64++ target: google_apis++ disable-animations: true++ script: sh android/scripts/screenshots.sh shots++ - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3++ if: always()++ with:++ name: screenshots++ path: shots/++ if-no-files-found: warn@@@ -7,3 +7,13 @@ __pycache__/*.egg-info//build/esp32/test/.deps/++++# Android (android/): the AAR is bound by android/scripts/build-core.sh, never committed.++android/.gradle/++android/build/++android/app/build/++android/app/libs/++android/local.properties++android/keystore.properties++android/.kotlin/++*.aar@@@ -106,6 +106,12 @@ tasks this machine ran today, and what happened, and lets you pause everything,share a port, or send files (not in this version). It is served only on loopback, with a per-run token in the addressfragment that every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.++**Link on your phone (Android, v0):** the same pairing and connection as the desktop Node, in an app: your agents'++approvals in your pocket (money, passwords, deletions and a send to a new recipient ask for a fingerprint first), the++Dock's activity read-only, and **Send to OpenVibe** from any app's share sheet to your Download drive. It pairs from++openvibe.bot/link/phone and shows up as a device of kind `phone`. No phone automation at all in this version: no++AccessibilityService, no screen capture. Build, install and the security notes: [docs/android.md](docs/android.md).++Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driverprotocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),[panel](docs/panel.md) (Link's own window and its security rules),@@@ -24,7 +24,8 @@"one-command install on a Raspberry Pi or other Linux robot (I2C, SPI, camera tools, kit detection)","system service on systemd, launchd and Windows (or a Windows sign-in start)","ESP32 library (OpenVibeNode): pairing, control link, drive and actuator commands, telemetry, heartbeat, deadman and e-stop",-- "`openvibe-node exchange serve --model <id>`: serve a local Ollama/llama.cpp/vLLM model through an OpenAI-compatible listener and register it on OpenVibe.Exchange's endpoint-seller API with the person's OPENVIBE_DEVELOPER_KEY, then remove the offer on Ctrl-C"++ "`openvibe-node exchange serve --model <id>`: serve a local Ollama/llama.cpp/vLLM model through an OpenAI-compatible listener and register it on OpenVibe.Exchange's endpoint-seller API with the person's OPENVIBE_DEVELOPER_KEY, then remove the offer on Ctrl-C",++ "Link for Android (v0, android/): the same Go pairing and connection bound with gomobile, a Kotlin + Jetpack Compose app (Pair, Approvals with a fingerprint for money, passwords, deletions and new recipients, Activity, This phone), Stay connected as an opt-in foreground service, and Send to OpenVibe on the share sheet; no phone automation"],"notYet": ["code signing and notarization for Windows and macOS (the release has SHA256SUMS only)",nnew file mode 100644@@@ -0,0 +1,115 @@++import java.util.Properties++++plugins {++ id("com.android.application")++ id("org.jetbrains.kotlin.android")++ id("org.jetbrains.kotlin.plugin.compose")++ id("org.jetbrains.kotlin.plugin.serialization")++ // Screenshots of every screen from the sample data (src/test/.../ScreensSnapshotTest.kt).++ id("app.cash.paparazzi")++}++++// The Go core, bound by gomobile (android/scripts/build-core.sh) into android/app/libs/core.aar. It is built, never++// committed; the build says so instead of failing with an unresolved symbol.++val coreAar = layout.projectDirectory.file("libs/core.aar")++if (!coreAar.asFile.exists()) {++ throw GradleException("android/app/libs/core.aar is missing: run android/scripts/build-core.sh first (it needs the Android NDK).")++}++++// A release build is signed only when the runner has a key (repo secrets): GH Actions writes android/keystore.properties.++val signingProps = rootProject.file("keystore.properties").takeIf { it.exists() }++ ?.let { Properties().apply { it.inputStream().use(::load) } }++++// The server a build talks to. A release build always carries https://openvibe.network; a debug build may be pointed++// elsewhere (`-Pnetwork.url=https://staging.example`) and then says so in a banner on every screen. A QR or a link that++// names another server is refused, never followed (data/Servers.kt, data/PairLinks.kt).++val officialServer = "https://openvibe.network"++val networkUrl = (project.findProperty("network.url") as String?)?.trimEnd('/') ?: officialServer++++android {++ namespace = "network.openvibe.link"++ compileSdk = 35++++ defaultConfig {++ applicationId = "network.openvibe.link"++ minSdk = 26++ targetSdk = 35++ versionCode = 1++ versionName = "0.1.0"++ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"++ }++++ signingConfigs {++ if (signingProps != null) {++ create("release") {++ storeFile = file(signingProps.getProperty("storeFile"))++ storePassword = signingProps.getProperty("storePassword")++ keyAlias = signingProps.getProperty("keyAlias")++ keyPassword = signingProps.getProperty("keyPassword")++ }++ }++ }++++ buildTypes {++ debug {++ buildConfigField("String", "NETWORK_URL", "\"$networkUrl\"")++ buildConfigField("boolean", "DEBUG_SERVER", "${networkUrl != officialServer}")++ }++ release {++ buildConfigField("String", "NETWORK_URL", "\"$officialServer\"")++ buildConfigField("boolean", "DEBUG_SERVER", "false")++ isMinifyEnabled = false++ if (signingProps != null) signingConfig = signingConfigs.getByName("release")++ }++ }++++ compileOptions {++ sourceCompatibility = JavaVersion.VERSION_17++ targetCompatibility = JavaVersion.VERSION_17++ }++++ kotlinOptions {++ jvmTarget = "17"++ }++++ buildFeatures {++ buildConfig = true++ compose = true++ }++++ packaging {++ resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}", "/META-INF/DEPENDENCIES")++ }++}++++dependencies {++ implementation(files("libs/core.aar"))++++ implementation("androidx.core:core-ktx:1.13.1")++ implementation("androidx.appcompat:appcompat:1.7.0")++ implementation("androidx.activity:activity-compose:1.9.3")++ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")++ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")++++ val composeBom = platform("androidx.compose:compose-bom:2024.10.01")++ implementation(composeBom)++ implementation("androidx.compose.ui:ui")++ implementation("androidx.compose.ui:ui-tooling-preview")++ implementation("androidx.compose.material3:material3")++ // The generated kit (OvKit.kt) names Icons.Filled.Error and Icons.Filled.ExpandMore, which are in the extended set.++ implementation("androidx.compose.material:material-icons-extended")++ debugImplementation("androidx.compose.ui:ui-tooling")++++ implementation("androidx.biometric:biometric:1.1.0")++ implementation("androidx.browser:browser:1.8.0")++++ implementation("com.squareup.okhttp3:okhttp:4.12.0")++ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")++ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")++++ // The QR the web shows: the same library Bot's link page builds the code with, in an embedded scanner.++ implementation("com.journeyapps:zxing-android-embedded:4.3.0")++++ testImplementation("junit:junit:4.13.2")++ androidTestImplementation("androidx.test.ext:junit:1.2.1")++}nnew file mode 100644@@@ -0,0 +1,10 @@++<?xml version="1.0" encoding="utf-8"?>++<manifest xmlns:android="http://schemas.android.com/apk/res/android">++ <!-- Debug only: the screenshot job starts one screen with sample data. Never in a release APK. -->++ <application>++ <activity++ android:name=".PreviewActivity"++ android:exported="true"++ android:theme="@style/Theme.OpenVibe" />++ </application>++</manifest>nnew file mode 100644@@@ -0,0 +1,85 @@++package network.openvibe.link++++import android.os.Bundle++import androidx.compose.foundation.layout.fillMaxSize++import androidx.activity.ComponentActivity++import androidx.activity.compose.setContent++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.Surface++import androidx.compose.ui.Modifier++import network.openvibe.link.data.ActionItem++import network.openvibe.link.ui.ActivityScreen++import network.openvibe.link.ui.ApprovalsScreen++import network.openvibe.link.ui.LinkTheme++import network.openvibe.link.ui.PairScreen++import network.openvibe.link.ui.PhoneScreen++import org.json.JSONObject++++/**++ * Debug builds only: draws one screen with sample data, so a screenshot job (or a designer) can see every screen++ * without an account. It is not in a release APK: it lives in src/debug.++ *++ * adb shell am start -n network.openvibe.link/.PreviewActivity --es screen approvals++ */++class PreviewActivity : ComponentActivity() {++ override fun onCreate(savedInstanceState: Bundle?) {++ super.onCreate(savedInstanceState)++ val screen = intent.getStringExtra("screen") ?: "approvals"++ setContent {++ LinkTheme {++ // The theme's own background: the screens assume the app's Scaffold paints it.++ Surface(++ modifier = Modifier.fillMaxSize(),++ color = MaterialTheme.colorScheme.background,++ ) {++ when (screen) {++ "activity" -> ActivityScreen(preview = SAMPLE_ACTIVITY)++ "phone" -> PhoneScreen(previewInfo = SAMPLE_DEVICE, preview = true)++ "pair" -> PairScreen(initialCode = "ABCD-1234")++ else -> ApprovalsScreen(preview = SAMPLE_APPROVALS)++ }++ }++ }++ }++ }++++ companion object {++ private fun item(++ id: String,++ title: String,++ summary: String?,++ bucket: String = "waiting",++ kind: String = "approval",++ agent: String? = "Actor",++ at: String = "2026-11-07T09:24:00Z",++ ) = ActionItem(++ service = "actor", id = id, kind = kind, title = title, summary = summary, bucket = bucket,++ state = if (bucket == "waiting") "pending" else "done", agent = agent,++ createdAt = at, updatedAt = at, actions = listOf("approve", "decline", "open"),++ )++++ val SAMPLE_APPROVALS = listOf(++ item("apr_1", "Renew the domain for another year", "It would charge 14 USD to the card on file."),++ item("apr_2", "Use the password vault to sign in to the registrar", "The agent needs the saved password."),++ item("apr_3", "Delete the old nightly backups", "Twelve archives from last year would be removed."),++ item("apr_4", "Email the finished report to Dana", "Dana is not in your contacts yet."),++ item("apr_5", "Write a summary of yesterday's notes", null),++ )++++ val SAMPLE_ACTIVITY = listOf(++ item("tsk_1", "Reviewing your pull request", "Step 3 of 7 · reading the diff", bucket = "running", kind = "agent_task", at = "2026-11-07T09:31:00Z"),++ item("tsk_2", "Monthly invoice for OpenVibe.Host", "Waiting for you: money", bucket = "waiting", kind = "agent_task", at = "2026-11-07T09:20:00Z"),++ item("tsk_3", "Nightly catch-up", "Runs at 03:00 on your computer", bucket = "scheduled", kind = "agent_task", at = "2026-11-07T03:00:00Z"),++ item("tsk_4", "Deploy the staging build", "Finished in 4m 12s", bucket = "done", kind = "agent_task", at = "2026-11-06T18:04:00Z"),++ item("tsk_5", "Index the photo library", "Stopped: the drive was full", bucket = "failed", kind = "agent_task", at = "2026-11-06T12:10:00Z"),++ )++++ val SAMPLE_DEVICE = JSONObject()++ .put("device_id", "dev_01J9Z8K4Q7X2M5N8P3R6T0V")++ .put("principal", "nod_01J9Z8K4Q7X2M5N8P3R6T0V")++ .put("server", "https://openvibe.bot")++ .put("network", "https://openvibe.network")++ .put("kind", "phone")++ .put("bound", true)++ }++}nnew file mode 100644@@@ -0,0 +1,81 @@++<?xml version="1.0" encoding="utf-8"?>++<manifest xmlns:android="http://schemas.android.com/apk/res/android">++++ <uses-permission android:name="android.permission.INTERNET" />++ <uses-permission android:name="android.permission.CAMERA" />++ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />++ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />++ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />++ <uses-permission android:name="android.permission.USE_BIOMETRIC" />++ <uses-permission android:name="android.permission.WAKE_LOCK" />++++ <uses-feature android:name="android.hardware.camera" android:required="false" />++++ <application++ android:name=".LinkApplication"++ android:allowBackup="false"++ android:dataExtractionRules="@xml/data_extraction_rules"++ android:fullBackupContent="false"++ android:icon="@drawable/ic_launcher"++ android:label="@string/app_name"++ android:supportsRtl="true"++ android:theme="@style/Theme.OpenVibe">++++ <activity++ android:name=".MainActivity"++ android:exported="true"++ android:launchMode="singleTask"++ android:windowSoftInputMode="adjustResize">++ <intent-filter>++ <action android:name="android.intent.action.MAIN" />++ <category android:name="android.intent.category.LAUNCHER" />++ </intent-filter>++ <!-- The QR on openvibe.bot/link/phone opens the app with the code filled in. -->++ <intent-filter>++ <action android:name="android.intent.action.VIEW" />++ <category android:name="android.intent.category.DEFAULT" />++ <category android:name="android.intent.category.BROWSABLE" />++ <data android:scheme="openvibe-link" android:host="pair" />++ </intent-filter>++ <!-- The sign-in returns here: an https App Link, claimed for this package by++ https://openvibe.network/.well-known/assetlinks.json (server/auth/app-links.js in OpenVibe.Network),++ so no other app can answer the redirect. A custom scheme could be registered by any app, so there is++ none for sign-in: openvibe-link:// carries pair codes only. The code is exchanged over PKCE and++ never carries a secret. -->++ <intent-filter android:autoVerify="true">++ <action android:name="android.intent.action.VIEW" />++ <category android:name="android.intent.category.DEFAULT" />++ <category android:name="android.intent.category.BROWSABLE" />++ <data android:scheme="https" android:host="openvibe.network" android:pathPrefix="/link/auth" />++ </intent-filter>++ </activity>++++ <!-- "Send to OpenVibe" from any app. -->++ <activity++ android:name=".ShareActivity"++ android:exported="true"++ android:label="@string/share_label"++ android:theme="@style/Theme.OpenVibe.Translucent">++ <intent-filter>++ <action android:name="android.intent.action.SEND" />++ <category android:name="android.intent.category.DEFAULT" />++ <data android:mimeType="*/*" />++ </intent-filter>++ <intent-filter>++ <action android:name="android.intent.action.SEND_MULTIPLE" />++ <category android:name="android.intent.category.DEFAULT" />++ <data android:mimeType="*/*" />++ </intent-filter>++ </activity>++++ <service++ android:name=".service.StayConnectedService"++ android:exported="false"++ android:foregroundServiceType="dataSync" />++++ <service++ android:name=".service.ShareUploadService"++ android:exported="false"++ android:foregroundServiceType="dataSync" />++ </application>++</manifest>nnew file mode 100644@@@ -0,0 +1,649 @@++// The OpenVibe kit for Jetpack Compose: a kit document, rendered.++//++// A kit document is a JSON tree of { type, props, children } (openvibe-shared/components). The web draws it with++// kit.js; this file draws the same document with the kit's own Compose views (OvKit.kt), wearing the theme's tokens++// (the Material 3 colour scheme and OvScale).++//++// val doc = KitNode.parse(json)++// MaterialTheme(colorScheme = VibeDarkColorScheme) {++// KitView(doc) { action -> open(action) }++// }++//++// The JSON is read with org.json, the parser Android carries in the platform.++package network.openvibe.kit++++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.lazy.items++import androidx.compose.foundation.shape.RoundedCornerShape++import androidx.compose.material3.Icon++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.NavigationDrawerItem++import androidx.compose.material3.Surface++import androidx.compose.material3.Text++import androidx.compose.runtime.Composable++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.Modifier++import androidx.compose.ui.graphics.vector.ImageVector++import androidx.compose.ui.text.font.FontWeight++import network.openvibe.theme.OvScale++import org.json.JSONArray++import org.json.JSONObject++++/** What a tap asks the app to do: open a link, or run the action a button names. */++sealed class KitAction {++ /** Follow a link (a card, a row, a button with an href). */++ data class Open(val href: String) : KitAction()++++ /** Run the action a button names (the app decides what that means). */++ data class Run(val id: String) : KitAction()++++ /** Share a link: the app opens the platform's own share sheet (Android's chooser, with Direct Share). */++ data class Share(val title: String, val url: String) : KitAction()++}++++/** A link the kit draws: where it goes and what it says. */++data class KitLink(val href: String, val label: String)++++/** One tab: its key (what the web puts in the URL), its label and its count. */++data class KitTab(val key: String, val label: String, val count: Int? = null)++++/** One row of a sidebar's navigation: where it goes, its name, its icon, whether you are on it, and a count. */++data class KitNav(val href: String, val label: String, val icon: String? = null, val here: Boolean = false, val count: Int? = null)++++/** One number with its label. */++data class KitStat(val value: String, val label: String, val href: String? = null)++++/**++ * A node of a kit document: one component, with its props and its children.++ *++ * The sealed cases are the kit's components; every one of them has a reference view in OvKit.kt that this file++ * draws with. A new component in components.js needs a case here (and a view), or the kit check fails.++ */++sealed class KitNode {++ /** The component's type, as it is written in the document. */++ abstract val type: String++++ /** The component's children, in document order. */++ abstract val children: List<KitNode>++++ /** A button: one action, a link or a run. */++ data class Button(++ val label: String,++ val href: String? = null,++ val icon: String? = null,++ val kind: OvKind = OvKind.Primary,++ val small: Boolean = false,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "button"++ }++++ /** A card: one thing, the whole card is one link. */++ data class Card(++ val href: String,++ val title: String,++ val variant: String? = null,++ val ratio: Float? = null,++ val image: String? = null,++ val alt: String? = null,++ val badge: String? = null,++ val live: Boolean = false,++ val corner: String? = null,++ val sub: String? = null,++ val preview: String? = null,++ val who: String? = null,++ val avatar: String? = null,++ val count: String? = null,++ val shownAt: String? = null,++ val level: Int? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "card"++++ /** The media's shape, as the view takes it. */++ val ratioValue: Float get() = ratio ?: 16f / 9f++++ /** The line under the title: who, the count, when. */++ val metaText: String?++ get() = listOfNotNull(who, count, shownAt).joinToString(" · ").ifEmpty { null }++++ /** The badge: the one the document gives, or LIVE. */++ val badgeText: String? get() = badge ?: if (live) "LIVE" else null++ }++++ /** A rail: a row of cards that scrolls sideways. */++ data class Rail(++ val title: String,++ val id: String? = null,++ val more: KitLink? = null,++ val size: String = "m",++ val wrap: Boolean = false,++ val label: String? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "rail"++++ /** The cards the rail holds. */++ val cards: List<Card> get() = children.filterIsInstance<Card>()++ }++++ /** A row: one item in a list. */++ data class Row(++ val title: String,++ val href: String? = null,++ val rel: String? = null,++ val meta: String? = null,++ val sub: String? = null,++ val mark: String? = null,++ val unread: Int? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "row"++ }++++ /** A list: rows in one rounded panel. */++ data class ListPanel(++ val title: String? = null,++ val more: KitLink? = null,++ val empty: String? = null,++ val flush: Boolean = false,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "list"++++ /** The rows the list holds. */++ val rows: List<Row> get() = children.filterIsInstance<Row>()++ }++++ /** A section: a titled group of anything. */++ data class Section(++ val title: String,++ val id: String? = null,++ val count: Int? = null,++ val note: String? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "section"++ }++++ /** A fold: a group that opens and closes. */++ data class Fold(++ val title: String,++ val id: String? = null,++ val meta: String? = null,++ val open: Boolean = false,++ val tone: String? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "fold"++ }++++ /** Tabs: the sections of a screen, one at a time. */++ data class Tabs(++ val items: List<KitTab> = emptyList(),++ val base: String? = null,++ val active: String? = null,++ val param: String = "tab",++ val label: String = "Sections",++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "tabs"++++ /** The tab that is open: the one the document names, or the first. */++ val selectedIndex: Int++ get() {++ val i = items.indexOfFirst { it.key == active }++ return if (i >= 0) i else 0++ }++ }++++ /** Sidebar: an app's navigation, as rows (on a phone the app shows it in its drawer). */++ data class Sidebar(++ val label: String = "Navigation",++ val nav: List<KitNav> = emptyList(),++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "sidebar"++ }++++ /** Stats: the numbers, in a row. */++ data class Stats(++ val items: List<KitStat> = emptyList(),++ val label: String = "In numbers",++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "stats"++ }++++ /** A notice: one line that says how things stand. */++ data class Notice(++ val text: String,++ val kind: OvNoticeKind = OvNoticeKind.Info,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "notice"++ }++++ /** The Share button: the platform's own share sheet for the link (Android's chooser, with Direct Share). */++ data class Share(++ val title: String,++ val url: String? = null,++ val label: String = "Share",++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "share"++ }++++ /** An empty state: what a screen shows when it has nothing to show. */++ data class Empty(++ val title: String,++ val text: String? = null,++ val icon: String? = null,++ val action: KitLink? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "empty"++ }++++ companion object {++ /** A document, as JSON, into its nodes. Throws when a node is not a component the kit knows. */++ fun parse(json: String): KitNode = parse(JSONObject(json))++++ /** A node, as JSON, into its component. Throws when the type is not one the kit knows. */++ fun parse(obj: JSONObject): KitNode {++ val type = obj.optString("type", "")++ val props = obj.optJSONObject("props") ?: JSONObject()++ val kids = childrenOf(obj.optJSONArray("children"))++ return when (type) {++ "button" -> Button(++ label = props.str("label") ?: "",++ href = props.str("href"),++ icon = props.str("icon"),++ kind = when (props.str("kind")) {++ "quiet" -> OvKind.Quiet++ "danger" -> OvKind.Danger++ else -> OvKind.Primary++ },++ small = props.bool("small"),++ )++ "card" -> Card(++ href = props.str("href") ?: "",++ title = props.str("title") ?: "",++ variant = props.str("type"),++ ratio = ratioOf(props.str("ratio")),++ image = props.str("image"),++ alt = props.str("alt"),++ badge = props.str("badge"),++ live = props.bool("live"),++ corner = props.str("corner"),++ sub = props.str("sub"),++ preview = props.str("preview"),++ who = props.str("who"),++ avatar = props.str("avatar"),++ count = props.count("count"),++ shownAt = props.str("when"),++ level = props.int("level"),++ )++ "rail" -> Rail(++ title = props.str("title") ?: "",++ id = props.str("id"),++ more = props.link("more"),++ size = props.str("size") ?: "m",++ wrap = props.bool("wrap"),++ label = props.str("label"),++ children = kids,++ )++ "row" -> Row(++ title = props.str("title") ?: "",++ href = props.str("href"),++ rel = props.str("rel"),++ meta = props.str("meta"),++ sub = props.str("sub"),++ mark = props.str("mark"),++ unread = props.int("unread"),++ )++ "list" -> ListPanel(++ title = props.str("title"),++ more = props.link("more"),++ empty = props.str("empty"),++ flush = props.bool("flush"),++ children = kids,++ )++ "section" -> Section(++ title = props.str("title") ?: "",++ id = props.str("id"),++ count = props.int("count"),++ note = props.str("note"),++ children = kids,++ )++ "fold" -> Fold(++ title = props.str("title") ?: "",++ id = props.str("id"),++ meta = props.str("meta"),++ open = props.bool("open"),++ tone = props.str("tone"),++ children = kids,++ )++ "tabs" -> Tabs(++ items = tabsOf(props.array("items")),++ base = props.str("base"),++ active = props.str("active"),++ param = props.str("param") ?: "tab",++ label = props.str("label") ?: "Sections",++ )++ "sidebar" -> Sidebar(++ label = props.str("label") ?: "Navigation",++ nav = navsOf(props.array("nav")),++ )++ "stats" -> Stats(++ items = statsOf(props.array("items")),++ label = props.str("label") ?: "In numbers",++ )++ "notice" -> Notice(++ text = props.str("text") ?: "",++ kind = when (props.str("kind")) {++ "ok" -> OvNoticeKind.Ok++ "warn" -> OvNoticeKind.Warn++ "error" -> OvNoticeKind.Error++ else -> OvNoticeKind.Info++ },++ )++ "share" -> Share(++ title = props.str("title") ?: "",++ url = props.str("url"),++ label = props.str("label") ?: "Share",++ )++ "empty" -> Empty(++ title = props.str("title") ?: "",++ text = props.str("text"),++ icon = props.str("icon"),++ action = props.link("action"),++ )++ else -> throw IllegalArgumentException("unknown kit component: " + type)++ }++ }++++ /** The children of a node, parsed. */++ private fun childrenOf(array: JSONArray?): List<KitNode> {++ if (array == null) return emptyList()++ val out = ArrayList<KitNode>()++ for (i in 0 until array.length()) {++ val child = array.optJSONObject(i) ?: continue++ out.add(parse(child))++ }++ return out++ }++++ /** The tabs of a tab row. */++ private fun tabsOf(array: JSONArray?): List<KitTab> {++ if (array == null) return emptyList()++ val out = ArrayList<KitTab>()++ for (i in 0 until array.length()) {++ val tab = array.optJSONObject(i) ?: continue++ val key = tab.str("key") ?: continue++ out.add(KitTab(key, tab.str("label") ?: key, tab.int("count")))++ }++ return out++ }++++ /** The rows of a sidebar's navigation. */++ private fun navsOf(array: JSONArray?): List<KitNav> {++ if (array == null) return emptyList()++ val out = ArrayList<KitNav>()++ for (i in 0 until array.length()) {++ val row = array.optJSONObject(i) ?: continue++ val href = row.str("href") ?: continue++ out.add(KitNav(href, row.str("label") ?: href, row.str("icon"), row.bool("here"), row.int("count")))++ }++ return out++ }++++ /** The numbers of a stats row. */++ private fun statsOf(array: JSONArray?): List<KitStat> {++ if (array == null) return emptyList()++ val out = ArrayList<KitStat>()++ for (i in 0 until array.length()) {++ val row = array.optJSONArray(i) ?: continue++ val value = row.optString(0, "")++ val label = row.optString(1, "")++ val href = if (row.length() > 2 && !row.isNull(2)) row.optString(2, "") else null++ out.add(KitStat(value, label, href))++ }++ return out++ }++ }++}++++// ── Reading the props ──++++/** A string prop, or nothing when the document does not carry it. */++private fun JSONObject.str(key: String): String? = if (has(key) && !isNull(key)) optString(key, "") else null++++/** A boolean prop. */++private fun JSONObject.bool(key: String, def: Boolean = false): Boolean =++ if (has(key) && !isNull(key)) optBoolean(key, def) else def++++/** A number prop. */++private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null++++/** An array prop. */++private fun JSONObject.array(key: String): JSONArray? = if (has(key) && !isNull(key)) optJSONArray(key) else null++++/** A link prop: { href, label }. */++private fun JSONObject.link(key: String): KitLink? {++ val obj = optJSONObject(key) ?: return null++ val href = obj.str("href") ?: return null++ return KitLink(href, obj.str("label") ?: href)++}++++/** A count prop: a number, or [number, word]. */++private fun JSONObject.count(key: String): String? {++ val value = opt(key) ?: return null++ return when (value) {++ is Number -> value.toString()++ is String -> value++ is JSONArray -> {++ val number = value.opt(0)++ val word = value.optString(1, "")++ if (number == null) null else if (word.isEmpty()) number.toString() else number.toString() + " " + word++ }++ else -> null++ }++}++++/** A ratio prop: "16:9" and the like, as the number the view takes. */++private fun ratioOf(raw: String?): Float? {++ if (raw == null) return null++ val parts = raw.split(":")++ if (parts.size == 2) {++ val a = parts[0].trim().toFloatOrNull()++ val b = parts[1].trim().toFloatOrNull()++ if (a != null && b != null && b != 0f) return a / b++ }++ return raw.trim().toFloatOrNull()++}++++// ── Drawing it ──++++/**++ * A kit document, drawn.++ *++ * The app wears the theme's Material 3 colour scheme around it (see the kit's preamble), and this draws every++ * component with the kit's own views. A tap comes back as a KitAction, so the app decides what a link or a button++ * does.++ */++@Composable++fun KitView(++ doc: KitNode,++ icons: Map<String, ImageVector> = emptyMap(),++ onAction: (KitAction) -> Unit = {},++) {++ Column(Modifier.fillMaxWidth()) {++ KitNodeView(doc, icons, onAction)++ }++}++++/** One node, drawn with the kit's view for its component. */++@Composable++fun KitNodeView(++ node: KitNode,++ icons: Map<String, ImageVector> = emptyMap(),++ onAction: (KitAction) -> Unit = {},++) {++ when (node) {++ is KitNode.Button -> OvButton(++ label = node.label,++ onClick = {++ val href = node.href++ if (href != null) onAction(KitAction.Open(href)) else onAction(KitAction.Run(node.label))++ },++ kind = node.kind,++ icon = node.icon?.let { icons[it] },++ )++ is KitNode.Card -> OvCard(++ title = node.title,++ onOpen = { onAction(KitAction.Open(node.href)) },++ image = node.image,++ ratio = node.ratioValue,++ badge = node.badgeText,++ sub = node.sub,++ meta = node.metaText,++ )++ is KitNode.Rail -> OvRail(++ title = node.title,++ seeAll = node.more?.let { more ->++ val href = more.href++ { onAction(KitAction.Open(href)) }++ },++ ) {++ items(node.cards) { card ->++ KitNodeView(card, icons, onAction)++ }++ }++ is KitNode.Row -> {++ val href = node.href++ val open: (() -> Unit)? = if (href != null) {++ val link = href++ { onAction(KitAction.Open(link)) }++ } else {++ null++ }++ val markName = node.mark++ val mark: (@Composable () -> Unit)? = if (markName != null) {++ val name = markName++ val slot: @Composable () -> Unit = { OvInitials(name) }++ slot++ } else {++ null++ }++ val unread = node.unread++ val trail: (@Composable () -> Unit)? = if (unread != null && unread > 0) {++ val count = unread++ val slot: @Composable () -> Unit = {++ Text(++ count.toString(),++ color = MaterialTheme.colorScheme.primary,++ fontWeight = FontWeight.Bold,++ fontSize = OvScale.TypeSizeMeta,++ )++ }++ slot++ } else {++ null++ }++ OvRow(++ title = node.title,++ onOpen = open,++ sub = node.sub,++ meta = node.meta,++ mark = mark,++ trail = trail,++ )++ }++ is KitNode.ListPanel -> OvList(title = node.title) {++ val rows = node.rows++ if (rows.isEmpty()) {++ val empty = node.empty++ if (empty != null) OvEmpty(empty)++ } else {++ rows.forEach { row ->++ KitNodeView(row, icons, onAction)++ }++ }++ }++ is KitNode.Section -> OvSection(title = node.title, note = node.note) {++ node.children.forEach { child ->++ KitNodeView(child, icons, onAction)++ }++ }++ is KitNode.Fold -> OvFold(title = node.title, meta = node.meta, initiallyOpen = node.open) {++ node.children.forEach { child ->++ KitNodeView(child, icons, onAction)++ }++ }++ is KitNode.Tabs -> {++ val labels = node.items.map { it.label }++ var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }++ OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })++ }++ is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)++ is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })++ is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)++ is KitNode.Share -> OvShare(title = node.title, url = node.url ?: "", label = node.label, onShare = { t, u -> onAction(KitAction.Share(t, u)) })++ is KitNode.Empty -> {++ val link = node.action++ val action: Pair<String, () -> Unit>? = if (link != null) {++ val href = link.href++ val label = link.label++ label to { onAction(KitAction.Open(href)) }++ } else {++ null++ }++ OvEmpty(title = node.title, text = node.text, action = action)++ }++ }++}++++/**++ * A row's mark: up to two letters on the theme's accent, when the document gives a name and no picture.++ */++@Composable++private fun OvInitials(name: String) {++ val letters = name.trim().take(2).uppercase()++ Surface(++ shape = RoundedCornerShape(OvScale.RadiusPill),++ color = MaterialTheme.colorScheme.primaryContainer,++ ) {++ Text(++ letters,++ Modifier.padding(horizontal = OvScale.SpaceSm, vertical = OvScale.SpaceXs),++ color = MaterialTheme.colorScheme.onPrimaryContainer,++ fontSize = OvScale.TypeSizeCaption,++ fontWeight = FontWeight.Bold,++ )++ }++}++++/** A sidebar's navigation, drawn as a drawer's rows: the one you are on is selected, and a tap opens its link. */++@Composable++fun OvSidebarNav(node: KitNode.Sidebar, icons: Map<String, ImageVector>, onAction: (KitAction) -> Unit) {++ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceMd)) {++ node.nav.forEach { item ->++ val vector = item.icon?.let { icons[it] }++ val count = item.count++ NavigationDrawerItem(++ label = { Text(item.label) },++ selected = item.here,++ onClick = { onAction(KitAction.Open(item.href)) },++ icon = if (vector != null) ({ Icon(vector, contentDescription = null) }) else null,++ badge = if (count != null && count > 0) ({ Text(count.toString()) }) else null,++ shape = RoundedCornerShape(OvScale.RadiusMd),++ )++ }++ }++}nnew file mode 100644@@@ -0,0 +1,248 @@++// The OpenVibe kit for Jetpack Compose: the reference views of components.js, with the imports they need.++// Generated by scripts/native-check.js — do not edit; fix components.js instead.++package network.openvibe.kit++++import androidx.compose.animation.AnimatedVisibility++import androidx.compose.foundation.BorderStroke++import androidx.compose.foundation.background++import androidx.compose.foundation.clickable++import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior++import androidx.compose.foundation.layout.Arrangement++import androidx.compose.foundation.layout.Box++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.ColumnScope++import androidx.compose.foundation.layout.PaddingValues++import androidx.compose.foundation.layout.Row++import androidx.compose.foundation.layout.RowScope++import androidx.compose.foundation.layout.Spacer++import androidx.compose.foundation.layout.aspectRatio++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.height++import androidx.compose.foundation.layout.heightIn++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.layout.size++import androidx.compose.foundation.layout.width++import androidx.compose.foundation.layout.widthIn++import androidx.compose.foundation.lazy.LazyListScope++import androidx.compose.foundation.lazy.LazyRow++import androidx.compose.foundation.lazy.items++import androidx.compose.foundation.lazy.rememberLazyListState++import androidx.compose.foundation.shape.RoundedCornerShape++import androidx.compose.material.icons.Icons++import androidx.compose.material.icons.filled.Error++import androidx.compose.material.icons.filled.ExpandMore++import androidx.compose.material.icons.filled.Info++import androidx.compose.material.icons.filled.Share++import androidx.compose.material3.Button++import androidx.compose.material3.ButtonDefaults++import androidx.compose.material3.DrawerState++import androidx.compose.material3.Icon++import androidx.compose.material3.ListItem++import androidx.compose.material3.LocalTextStyle++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.ModalDrawerSheet++import androidx.compose.material3.ModalNavigationDrawer++import androidx.compose.material3.NavigationDrawerItem++import androidx.compose.material3.OutlinedButton++import androidx.compose.material3.ScrollableTabRow++import androidx.compose.material3.Surface++import androidx.compose.material3.Tab++import androidx.compose.material3.Text++import androidx.compose.material3.TextButton++import androidx.compose.runtime.Composable++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.draw.clip++import androidx.compose.ui.draw.rotate++import androidx.compose.ui.graphics.Color++import androidx.compose.ui.graphics.vector.ImageVector++import androidx.compose.ui.layout.ContentScale++import androidx.compose.ui.semantics.LiveRegionMode++import androidx.compose.ui.semantics.heading++import androidx.compose.ui.semantics.liveRegion++import androidx.compose.ui.semantics.semantics++import androidx.compose.ui.text.font.FontWeight++import androidx.compose.ui.text.style.TextAlign++import androidx.compose.ui.text.style.TextOverflow++import androidx.compose.ui.unit.dp++import androidx.compose.ui.unit.sp++import network.openvibe.theme.OvScale++++// OpenVibe kit for Jetpack Compose: add the theme file from openvibe.design/tokens (Android) and wrap the app in++// MaterialTheme(colorScheme = if (isSystemInDarkTheme()) VibeDarkColorScheme else VibeLightColorScheme) { … }++enum class OvKind { Primary, Quiet, Danger }++enum class OvNoticeKind { Ok, Info, Warn, Error }++++// Button (button)++@Composable++fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary, icon: ImageVector? = null) {++ val c = MaterialTheme.colorScheme++ val colors = when (kind) {++ OvKind.Primary -> ButtonDefaults.buttonColors(containerColor = c.primaryContainer, contentColor = c.onPrimaryContainer)++ OvKind.Danger -> ButtonDefaults.buttonColors(containerColor = c.error, contentColor = c.onError)++ OvKind.Quiet -> ButtonDefaults.outlinedButtonColors(contentColor = c.onSurface)++ }++ val content: @Composable RowScope.() -> Unit = {++ if (icon != null) { Icon(icon, contentDescription = null, Modifier.size(OvScale.SizeIcon)); Spacer(Modifier.width(OvScale.SpaceSm)) }++ Text(label, fontWeight = FontWeight.Bold)++ }++ val m = Modifier.heightIn(min = OvScale.SizeTap)++ if (kind == OvKind.Quiet) OutlinedButton(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, border = BorderStroke(1.dp, c.outline), content = content)++ else Button(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, content = content)++}++++// Card (card)++@Composable++fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Float = 16f / 9f, badge: String? = null, sub: String? = null, meta: String? = null) {++ val c = MaterialTheme.colorScheme++ Column(Modifier.clickable(onClickLabel = title, onClick = onOpen).semantics(mergeDescendants = true) { heading() }) {++ Box(Modifier.fillMaxWidth().aspectRatio(ratio).clip(RoundedCornerShape(OvScale.RadiusMd)).background(c.surfaceVariant)) {++ if (image != null) AsyncImage(image, contentDescription = null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)++ if (badge != null) Text(badge, Modifier.padding(OvScale.SpaceSm).background(Color(0xBD000000), RoundedCornerShape(6.dp)).padding(horizontal = 8.dp), color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp)++ }++ Spacer(Modifier.height(OvScale.SpaceMd))++ Text(title, color = c.onSurface, fontWeight = FontWeight.Bold, fontSize = OvScale.TypeSizeBody, maxLines = 2, overflow = TextOverflow.Ellipsis)++ if (sub != null) Text(sub, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1)++ if (meta != null) Text(meta, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1)++ }++}++++// Rail (rail)++@Composable++fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.() -> Unit) {++ Column(Modifier.semantics { heading() }) {++ Row(Modifier.fillMaxWidth().padding(horizontal = OvScale.SpaceLg), verticalAlignment = Alignment.CenterVertically) {++ Text(title, Modifier.weight(1f), fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeLead)++ if (seeAll != null) TextButton(seeAll, Modifier.heightIn(min = OvScale.SizeTap)) { Text("See all") }++ }++ val state = rememberLazyListState()++ LazyRow(state = state, flingBehavior = rememberSnapFlingBehavior(state), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg),++ horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceMd), content = content)++ }++}++++// Row (row)++@Composable++fun OvRow(title: String, onOpen: (() -> Unit)? = null, sub: String? = null, meta: String? = null, mark: (@Composable () -> Unit)? = null, trail: (@Composable () -> Unit)? = null) {++ ListItem(++ headlineContent = { Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) },++ supportingContent = sub?.let { { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = OvScale.TypeSizeMeta) } },++ leadingContent = mark, trailingContent = trail ?: meta?.let { { Text(it, fontSize = OvScale.TypeSizeMeta) } },++ modifier = (if (onOpen != null) Modifier.clickable(onClick = onOpen) else Modifier).heightIn(min = 56.dp),++ )++}++++// List (list)++@Composable++fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) {++ Column {++ if (title != null) Text(title.uppercase(), Modifier.padding(OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant)++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), tonalElevation = 1.dp) { Column(content = content) }++ }++}++++// Section (section)++@Composable++fun OvSection(title: String, note: String? = null, content: @Composable ColumnScope.() -> Unit) {++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {++ Column(Modifier.padding(vertical = OvScale.SpaceMd)) {++ Text(title.uppercase(), Modifier.padding(horizontal = OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant)++ if (note != null) Text(note, Modifier.padding(horizontal = OvScale.SpaceMd), fontSize = OvScale.TypeSizeMeta, color = MaterialTheme.colorScheme.onSurfaceVariant)++ content()++ }++ }++}++++// Fold (fold)++@Composable++fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false, content: @Composable () -> Unit) {++ var open by rememberSaveable { mutableStateOf(initiallyOpen) }++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd)) {++ Column {++ Row(Modifier.fillMaxWidth().heightIn(min = OvScale.SizeTap).clickable(onClickLabel = if (open) "Close" else "Open") { open = !open }.padding(horizontal = OvScale.SpaceMd),++ verticalAlignment = Alignment.CenterVertically) {++ Text(title, Modifier.weight(1f), fontWeight = FontWeight.Bold)++ if (meta != null) Text(meta, color = MaterialTheme.colorScheme.onSurfaceVariant)++ Icon(Icons.Default.ExpandMore, null, Modifier.rotate(if (open) 180f else 0f))++ }++ AnimatedVisibility(open) { Box(Modifier.padding(OvScale.SpaceMd)) { content() } }++ }++ }++}++++// Tabs (tabs)++@Composable++fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) {++ ScrollableTabRow(selectedTabIndex = selected, edgePadding = OvScale.SpaceLg) {++ tabs.forEachIndexed { i, t -> Tab(selected = i == selected, onClick = { onSelect(i) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), text = { Text(t) }) }++ }++}++++// Sidebar (sidebar)++@Composable++fun OvSidebar(items: List<Pair<String, ImageVector>>, current: Int, onPick: (Int) -> Unit, drawer: DrawerState, content: @Composable () -> Unit) {++ ModalNavigationDrawer(drawerState = drawer, drawerContent = {++ ModalDrawerSheet(Modifier.widthIn(max = 320.dp)) {++ Column(Modifier.padding(OvScale.SpaceMd)) {++ items.forEachIndexed { i, (label, icon) ->++ NavigationDrawerItem(label = { Text(label) }, icon = { Icon(icon, null, Modifier.size(OvScale.SizeIcon)) }, selected = i == current,++ onClick = { onPick(i) }, shape = RoundedCornerShape(OvScale.RadiusMd), modifier = Modifier.heightIn(min = OvScale.SizeTap))++ }++ }++ }++ }, content = content)++}++++// Numbers (stats)++@Composable++fun OvStats(items: List<Pair<String, String>>) {++ LazyRow(horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg)) {++ items(items) { (value, label) ->++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {++ Column(Modifier.widthIn(min = 104.dp).padding(horizontal = 14.dp, vertical = 8.dp).semantics(mergeDescendants = true) {}) {++ Text(value, fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeTitle, style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"))++ Text(label, fontSize = OvScale.TypeSizeCaption, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ }++ }++ }++}++++// Notice (notice)++@Composable++fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) {++ val c = MaterialTheme.colorScheme++ val tint = when (kind) { OvNoticeKind.Error -> c.error; OvNoticeKind.Ok -> c.primary; else -> c.tertiary }++ Row(Modifier.fillMaxWidth().semantics { liveRegion = if (kind == OvNoticeKind.Error) LiveRegionMode.Assertive else LiveRegionMode.Polite }++ .background(tint.copy(alpha = .12f), RoundedCornerShape(OvScale.RadiusSm)).padding(OvScale.SpaceMd), horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) {++ Icon(if (kind == OvNoticeKind.Error) Icons.Default.Error else Icons.Default.Info, null, tint = tint)++ Text(text, color = c.onSurface)++ }++}++++// Share (share)++@Composable++fun OvShare(title: String, url: String, label: String = "Share", onShare: (title: String, url: String) -> Unit) {++ // The app hands the link to the platform's own sheet, with Direct Share's contacts. On Android:++ // context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, url) }, title))++ OutlinedButton(onClick = { onShare(title, url) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), shape = RoundedCornerShape(OvScale.RadiusPill)) {++ Icon(Icons.Default.Share, contentDescription = null)++ Spacer(Modifier.width(OvScale.SpaceSm))++ Text(label, fontWeight = FontWeight.Bold)++ }++}++++// Empty state (empty)++@Composable++fun OvEmpty(title: String, text: String? = null, action: Pair<String, () -> Unit>? = null) {++ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceXl), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) {++ Text(title, fontWeight = FontWeight.Bold)++ if (text != null) Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)++ if (action != null) OvButton(action.first, action.second)++ }++}++nnew file mode 100644@@@ -0,0 +1,12 @@++// The check's stand-ins: what an app brings with it, not what the Compose artifacts carry.++// The kit's card view draws a picture through an image loader (Coil, Glide); the CI does not pull one in, it only++// needs the call to type-check.++package network.openvibe.kit++++import androidx.compose.runtime.Composable++import androidx.compose.ui.Modifier++import androidx.compose.ui.layout.ContentScale++++@Composable++fun AsyncImage(model: Any?, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) {++}nnew file mode 100644@@@ -0,0 +1,22 @@++package network.openvibe.link++++import android.app.Application++import network.openvibe.link.data.Store++import network.openvibe.link.service.ShareUploadService++import network.openvibe.link.service.StayConnectedService++++class LinkApplication : Application() {++ override fun onCreate() {++ super.onCreate()++ Store.init(this)++ StayConnectedService.ensureChannel(this)++ ShareUploadService.ensureChannel(this)++ if (Store.paired != null && Store.stayConnected) {++ try {++ StayConnectedService.start(this)++ } catch (e: Exception) {++ // Android may refuse a background start; the switch starts it the next time the app is opened.++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,65 @@++package network.openvibe.link++++import android.Manifest++import android.content.Intent++import android.content.pm.PackageManager++import android.net.Uri++import android.os.Build++import android.os.Bundle++import androidx.activity.result.contract.ActivityResultContracts++import androidx.core.content.ContextCompat++import androidx.fragment.app.FragmentActivity++import androidx.activity.compose.setContent++import network.openvibe.link.data.Api++import network.openvibe.link.data.LinkController++import network.openvibe.link.data.PairLinks++import network.openvibe.link.data.Store++import network.openvibe.link.ui.Incoming++import network.openvibe.link.ui.LinkApp++import network.openvibe.link.ui.LinkTheme++++/**++ * The app's one activity. It answers the pairing link (`openvibe-link://pair?code=…`) and the sign-in's return++ * (`https://openvibe.network/link/auth?code=…`, the claimed App Link); both arrive as intents, and the Compose tree++ * reads them through [Incoming]. A pair link naming another server is refused here, before anything pairs.++ */++class MainActivity : FragmentActivity() {++ private val askNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }++++ override fun onCreate(savedInstanceState: Bundle?) {++ super.onCreate(savedInstanceState)++ Store.init(applicationContext)++ read(intent)++ // Without "Stay connected" the link lives only while the app is on screen (walked back from in onStop).++ if (Store.paired != null && !Store.stayConnected) LinkController.start()++ setContent { LinkTheme { LinkApp() } }++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&++ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED++ ) {++ askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)++ }++ }++++ override fun onStop() {++ if (!Store.stayConnected) LinkController.stop()++ super.onStop()++ }++++ override fun onNewIntent(intent: Intent) {++ super.onNewIntent(intent)++ setIntent(intent)++ read(intent)++ }++++ private fun read(intent: Intent?) {++ val data = intent?.data ?: return++ if (data.host == PairLinks.PAIR_HOST) {++ val link = PairLinks.parse(data.toString())++ Incoming.pairRefused = link?.refusedServer++ Incoming.pairCode = if (link?.refusedServer != null) null else link?.code ?: data.getQueryParameter("code")++ return++ }++ val ours = data.scheme == "https" && data.host == Uri.parse(Api.NETWORK).host && data.path == PairLinks.AUTH_PATH++ if (ours) Incoming.authUri = data.toString()++ }++}nnew file mode 100644@@@ -0,0 +1,83 @@++package network.openvibe.link++++import android.content.Intent++import android.net.Uri++import android.os.Bundle++import android.widget.Toast++import androidx.activity.ComponentActivity++import androidx.activity.compose.setContent++import network.openvibe.link.data.Session++import network.openvibe.link.data.Store++import network.openvibe.link.service.ShareUploadService++import network.openvibe.link.share.ConfirmShare++import network.openvibe.link.share.ShareIntents++import network.openvibe.link.share.ShareRequest++import network.openvibe.link.share.ShareUploader++import network.openvibe.link.ui.LinkTheme++++/**++ * "Send to OpenVibe" from any app's share sheet. Nothing is sent on the intent alone: the sheet asks first, and only++ * "Send" starts the upload (the drive's own, the same one the web page uses) in a foreground service, with progress++ * in a notification. Cancel closes the sheet and the source app comes back with the file untouched.++ */++class ShareActivity : ComponentActivity() {++ override fun onCreate(savedInstanceState: Bundle?) {++ super.onCreate(savedInstanceState)++ Store.init(applicationContext)++++ val intent = intent++ val uris = mutableListOf<String>()++ val clip = intent.clipData++ if (clip != null && clip.itemCount > 0) {++ for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let { uris.add(it.toString()) }++ } else {++ @Suppress("DEPRECATION")++ (intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it.toString()) }++ }++ val request = ShareIntents.parse(++ intent.action,++ intent.type,++ uris,++ intent.getStringExtra(Intent.EXTRA_TEXT),++ )++ if (request == null) {++ Toast.makeText(this, "There is nothing to send.", Toast.LENGTH_SHORT).show()++ finish()++ return++ }++ if (Store.paired == null) {++ Toast.makeText(this, "Pair this phone with OpenVibe first.", Toast.LENGTH_LONG).show()++ startActivity(Intent(this, MainActivity::class.java))++ finish()++ return++ }++ if (!Session.hasSession()) {++ Toast.makeText(this, "Sign in to OpenVibe to send files.", Toast.LENGTH_LONG).show()++ startActivity(Intent(this, MainActivity::class.java))++ finish()++ return++ }++++ setContent {++ LinkTheme {++ ConfirmShare(++ request = request,++ onSend = { send(request); finish() },++ onCancel = { finish() },++ )++ }++ }++ }++++ private fun send(request: ShareRequest) {++ val files = request.uris.toMutableList()++ val names = mutableListOf<String>()++ if (files.isEmpty() && request.text != null) {++ val (uri, name) = ShareUploader.textFile(this, request.text)++ files.add(uri.toString())++ names.add(name)++ }++ ShareUploadService.start(this, files, names, request.mimeType)++ Toast.makeText(this, if (files.size > 1) "Sending ${files.size} files…" else "Sending to OpenVibe…", Toast.LENGTH_SHORT).show()++ }++}nnew file mode 100644@@@ -0,0 +1,71 @@++package network.openvibe.link.approval++++/**++ * What an approval is about. Four of these are consequential enough that the phone asks for a fingerprint, a face or++ * the device credential before it sends an answer: money, passwords, deletions and a send to someone new (plan++ * 2026-11-07 §13.2: "Money, passwords, deletions and sends need a fingerprint or face confirmation").++ *++ * The category is the item's own `category` when the service sends one; otherwise it is read from the approval's++ * kind, title and summary. Actor's approvals today are all `codes.task`, whose title is the person's own sentence, so++ * the words are the only signal a phone has — hence the tests beside this file.++ */++enum class ApprovalCategory(val wire: String, val label: String, val needsBiometrics: Boolean) {++ MONEY("money", "Money", true),++ PASSWORD("password", "Passwords and codes", true),++ DELETE("delete", "Deleting", true),++ NEW_RECIPIENT("new_recipient", "A new recipient", true),++ OTHER("other", "Other", false);++++ companion object {++ /** The service's own category, when it names one this app knows. */++ fun fromWire(value: String?): ApprovalCategory? =++ entries.firstOrNull { it.wire == value?.trim()?.lowercase() }++ }++}++++object ApprovalRules {++ // Whole words only: "display" is not a payment and "monetary" is.++ private val MONEY = Regex(++ "\\b(money|payment|pay|paid|price|cost|charge|invoice|refund|purchase|buy|transfer|tip|deposit|withdraw|" +++ "subscription|billing|checkout|stripe|paypal|card|usd|eur|gbp)\\b|[$€£¥]|\\d+[.,]?\\d*\\s?(usd|eur|gbp)",++ RegexOption.IGNORE_CASE,++ )++ private val PASSWORD = Regex(++ "\\b(password|passcode|passphrase|secret|credential|credentials|api[ _-]?key|token|2fa|otp|one[- ]time " +++ "code|authenticator|login|sign[- ]in|private key|seed phrase)\\b",++ RegexOption.IGNORE_CASE,++ )++ private val DELETE = Regex(++ "\\b(delete|deletes|deleting|deleted|remove|removes|removing|erase|erasing|wipe|wiping|destroy|purge|" +++ "cancel (my )?(plan|account|subscription)|close (my )?account|unlink|drop)\\b",++ RegexOption.IGNORE_CASE,++ )++ private val NEW_RECIPIENT = Regex(++ "\\b(new (recipient|contact|person|address|number|email|phone)|" +++ "(send|share|email|mail|text|message|forward|invite|introduce)\\b[^.]{0,60}\\bto\\b|" +++ "reply to|invite)\\b",++ RegexOption.IGNORE_CASE,++ )++++ /**++ * The category of one approval. An explicit category the service sent wins; otherwise the most consequential++ * reading of the words wins, so a sentence that both sends and pays is money, and a deletion is never shown as++ * something smaller.++ */++ fun classify(explicit: String?, kind: String?, title: String?, summary: String?): ApprovalCategory {++ ApprovalCategory.fromWire(explicit)?.let { return it }++ val text = listOfNotNull(kind, title, summary).joinToString(" ")++ if (text.isBlank()) return ApprovalCategory.OTHER++ return when {++ MONEY.containsMatchIn(text) -> ApprovalCategory.MONEY++ PASSWORD.containsMatchIn(text) -> ApprovalCategory.PASSWORD++ DELETE.containsMatchIn(text) -> ApprovalCategory.DELETE++ NEW_RECIPIENT.containsMatchIn(text) -> ApprovalCategory.NEW_RECIPIENT++ else -> ApprovalCategory.OTHER++ }++ }++++ /** True when answering this one needs a fingerprint, a face or the device credential first. */++ fun needsBiometrics(explicit: String?, kind: String?, title: String?, summary: String?): Boolean =++ classify(explicit, kind, title, summary).needsBiometrics++}nnew file mode 100644@@@ -0,0 +1,59 @@++package network.openvibe.link.approval++++import android.content.Context++import android.os.Build++import androidx.biometric.BiometricManager++import androidx.biometric.BiometricPrompt++import androidx.core.content.ContextCompat++import androidx.fragment.app.FragmentActivity++++/**++ * The lock before an answer that matters. BIOMETRIC_STRONG (a fingerprint or a face the phone trusts) with the device++ * credential behind it on Android 11+, and a fingerprint or the device credential on 8–10; the answer is only sent++ * from the success callback.++ */++object Biometrics {++ fun available(context: Context): Boolean {++ val manager = BiometricManager.from(context)++ val allowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {++ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL++ } else {++ BiometricManager.Authenticators.BIOMETRIC_STRONG++ }++ return manager.canAuthenticate(allowed) == BiometricManager.BIOMETRIC_SUCCESS++ }++++ fun confirm(++ activity: FragmentActivity,++ title: String,++ onSuccess: () -> Unit,++ onError: (String) -> Unit,++ ) {++ val prompt = BiometricPrompt(++ activity,++ ContextCompat.getMainExecutor(activity),++ object : BiometricPrompt.AuthenticationCallback() {++ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = onSuccess()++ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {++ if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_CANCELED) return++ onError(errString.toString())++ }++ override fun onAuthenticationFailed() {++ // One wrong finger: the prompt stays up and asks again.++ }++ },++ )++ val info = BiometricPrompt.PromptInfo.Builder()++ .setTitle("Confirm it's you")++ .setSubtitle(title)++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {++ info.setAllowedAuthenticators(++ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL,++ )++ } else {++ info.setNegativeButtonText("Cancel")++ info.setDeviceCredentialAllowed(true)++ }++ prompt.authenticate(info.build())++ }++}nnew file mode 100644@@@ -0,0 +1,131 @@++package network.openvibe.link.data++++import kotlinx.coroutines.Dispatchers++import kotlinx.coroutines.withContext++import kotlinx.serialization.SerialName++import kotlinx.serialization.Serializable++import kotlinx.serialization.json.Json++import okhttp3.MediaType.Companion.toMediaType++import okhttp3.OkHttpClient++import okhttp3.Request++import okhttp3.RequestBody.Companion.toRequestBody++import java.util.concurrent.TimeUnit++++/** One item of the Action Center (`common.action-item@1`), which is what the Dock's activity tab draws. */++@Serializable++data class ActionItem(++ val service: String = "",++ val id: String = "",++ val kind: String = "",++ val title: String = "",++ val summary: String? = null,++ val bucket: String = "waiting",++ val state: String? = null,++ val agent: String? = null,++ @SerialName("created_at") val createdAt: String? = null,++ @SerialName("updated_at") val updatedAt: String? = null,++ @SerialName("next_run_at") val nextRunAt: String? = null,++ val error: String? = null,++ val href: String? = null,++ val actions: List<String> = emptyList(),++ /** Actor's own category, when it sends one (money, password, delete, new_recipient). */++ val category: String? = null,++) {++ val isApproval: Boolean get() = kind == "approval"++ val isPending: Boolean get() = bucket == "waiting" || state == "pending"++}++++/** `network.me-actions-result@1`: the buckets, the counts, and whether a source did not answer. */++@Serializable++data class ActionsDoc(++ val buckets: Map<String, List<ActionItem>> = emptyMap(),++ val counts: Map<String, Int> = emptyMap(),++ val partial: Boolean = false,++)++++class ApiException(message: String) : Exception(message)++++/**++ * The person's own reads and answers on OpenVibe.Network, with the session token. Every route is under `/me`, and the++ * subject comes from the token, so this app can never read or answer another person's work.++ */++object Api {++ /** The server this build signs in and pairs with (Servers): a deep link never chooses it. */++ val NETWORK: String get() = Servers.network++ const val BOT = "https://openvibe.bot"++ const val DOWNLOAD = "https://openvibe.download"++++ const val APPROVE = "approve"++ const val DECLINE = "decline"++++ private val json = Json { ignoreUnknownKeys = true }++ private val client = OkHttpClient.Builder()++ .connectTimeout(15, TimeUnit.SECONDS)++ .readTimeout(30, TimeUnit.SECONDS)++ .build()++++ private val JSON_TYPE = "application/json; charset=utf-8".toMediaType()++++ suspend fun actions(): ActionsDoc = withContext(Dispatchers.IO) {++ val token = Session.token() ?: throw ApiException("Sign in to OpenVibe to see your approvals.")++ val request = Request.Builder()++ .url("$NETWORK/api/v1/me/actions")++ .header("Authorization", "Bearer $token")++ .header("Accept", "application/json")++ .get()++ .build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (response.code == 401) {++ Store.clearTokens()++ throw ApiException("Your session ended. Sign in again.")++ }++ if (!response.isSuccessful) throw ApiException("OpenVibe answered ${response.code}.")++ try {++ json.decodeFromString<ActionsDoc>(text)++ } catch (e: Exception) {++ throw ApiException("OpenVibe's answer could not be read.")++ }++ }++ }++++ /** Answer one Actor approval as the person themself (the same decision /me/approvals makes). */++ suspend fun decide(item: ActionItem, approve: Boolean): ActionItem = withContext(Dispatchers.IO) {++ if (item.service != "actor" || !item.isApproval) throw ApiException("This item is not decided here.")++ val token = Session.token() ?: throw ApiException("Sign in to OpenVibe to answer approvals.")++ val action = if (approve) APPROVE else DECLINE++ val url = "$NETWORK/api/v1/me/actions/${item.service}/${item.id}/$action"++ val request = Request.Builder()++ .url(url)++ .header("Authorization", "Bearer $token")++ .header("Accept", "application/json")++ .post("{}".toRequestBody(JSON_TYPE))++ .build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (response.code == 401) {++ Store.clearTokens()++ throw ApiException("Your session ended. Sign in again.")++ }++ if (!response.isSuccessful) throw ApiException(problem(text) ?: "OpenVibe answered ${response.code}.")++ val body = try {++ json.parseToJsonElement(text)++ } catch (e: Exception) {++ throw ApiException("OpenVibe's answer could not be read.")++ }++ val itemJson = (body as? kotlinx.serialization.json.JsonObject)?.get("item") ?: body++ try {++ json.decodeFromJsonElement(ActionItem.serializer(), itemJson)++ } catch (e: Exception) {++ item.copy(state = action)++ }++ }++ }++++ private fun problem(text: String): String? = try {++ (json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject)++ ?.get("detail")?.toString()?.trim('"')?.takeIf { it.isNotEmpty() }++ } catch (e: Exception) {++ null++ }++}nnew file mode 100644@@@ -0,0 +1,117 @@++package network.openvibe.link.data++++import android.os.Handler++import android.os.Looper++import androidx.compose.runtime.getValue++import androidx.compose.runtime.mutableStateOf++import androidx.compose.runtime.setValue++++/**++ * The one Go link this app holds, and the state the screens draw. `mobile.Link` is the AAR the Go core is bound into++ * (mobile/mobile.go): the same pairing and reconnecting WebSocket the Node runs. Kotlin only starts, stops and reads it.++ */++object LinkController {++ /** "stopped", "connected", "offline" or "revoked". */++ var state: String by mutableStateOf("stopped")++ private set++ var detail: String? by mutableStateOf(null)++ private set++ var connected: Boolean by mutableStateOf(false)++ private set++++ private val main = Handler(Looper.getMainLooper())++ private var link: mobile.Link? = null++++ @Synchronized++ fun start() {++ if (link != null) return++ val pairing = Store.paired ?: return++ val started = try {++ mobile.Mobile.newLink(pairing)++ } catch (e: Exception) {++ publish("revoked", "This phone's pairing could not be read. Pair it again.")++ return++ }++ link = started++ publish("offline", "Connecting…")++ try {++ started.start(object : mobile.Listener {++ override fun onState(event: String) = onGoState(event)++ })++ } catch (e: Exception) {++ publish("offline", "Could not start the connection.")++ link = null++ }++ }++++ @Synchronized++ fun stop() {++ val current = link ?: return++ link = null++ try {++ current.stop()++ } catch (e: Exception) {++ // The Go side is already gone; nothing to close.++ }++ publish("stopped", null)++ }++++ /** What "This phone" shows, from the Go core: the device, the account, the kind and whether it is bound. */++ fun info(): String? {++ val pairing = Store.paired ?: return null++ return try {++ mobile.Mobile.info(pairing)++ } catch (e: Exception) {++ null++ }++ }++++ private fun onGoState(event: String) {++ val state = eventState(event)++ val detail = eventDetail(event)++ main.post {++ when (state) {++ "connected" -> {++ connected = true++ this.state = "connected"++ this.detail = null++ }++ "offline" -> {++ connected = false++ this.state = "offline"++ this.detail = detail++ }++ "stopped" -> {++ connected = false++ this.state = "stopped"++ this.detail = null++ }++ "revoked" -> {++ connected = false++ this.state = "revoked"++ this.detail = detail++ }++ }++ }++ }++++ private fun publish(next: String, message: String?) {++ main.post {++ state = next++ detail = message++ connected = next == "connected"++ }++ }++++ private fun eventState(event: String): String = try {++ org.json.JSONObject(event).optString("state", "offline")++ } catch (e: Exception) {++ "offline"++ }++++ private fun eventDetail(event: String): String? = try {++ org.json.JSONObject(event).optString("detail").takeIf { it.isNotEmpty() }++ } catch (e: Exception) {++ null++ }++}nnew file mode 100644@@@ -0,0 +1,77 @@++package network.openvibe.link.data++++import java.net.URLDecoder++import java.net.URLEncoder++++/**++ * The two links the app answers: `openvibe-link://pair?code=…` (the QR on openvibe.bot/link/phone) and the sign-in's++ * return, an https App Link for `https://openvibe.network/link/auth` (MainActivity). Parsed here, away from Android,++ * so the shapes have tests.++ *++ * The pair link names no server, and if it names one it must be this build's (Servers.network): a link that points++ * elsewhere is refused, never followed, because the phone would otherwise send its pairing to whoever wrote the link.++ */++data class PairLink(++ val code: String?,++ val network: String?,++ val pairing: String? = null,++ /** The server a refused link named (its host when it parses, the raw value otherwise); null when the link is ours. */++ val refusedServer: String? = null,++)++++object PairLinks {++ const val PAIR_SCHEME = "openvibe-link"++ const val PAIR_HOST = "pair"++ const val AUTH_PATH = "/link/auth"++++ fun parse(uri: String?, accepted: String = Servers.network): PairLink? {++ if (uri.isNullOrBlank()) return null++ val trimmed = uri.trim()++ val prefix = "$PAIR_SCHEME://$PAIR_HOST"++ if (!trimmed.startsWith(prefix, ignoreCase = true)) return null++ val params = queryParams(trimmed.substringAfter('?', missingDelimiterValue = ""))++ return link(params, accepted)++ }++++ /** A scanned QR: our link, the code itself, or a page whose `code` query carries it. */++ fun codeFromScan(contents: String?, accepted: String = Servers.network): PairLink? {++ val text = contents?.trim().orEmpty()++ if (text.isEmpty()) return null++ parse(text, accepted)?.let { if (it.code != null || it.refusedServer != null) return it }++ val params = queryParams(text.substringAfter('?', missingDelimiterValue = ""))++ if (params["code"]?.isNotBlank() == true) return link(params, accepted)++ val bare = text.replace("-", "").replace(" ", "")++ return if (bare.length == 8 && bare.all { it.isLetterOrDigit() }) PairLink(text, accepted) else null++ }++++ /** The link's code, pairing id and server — refused whole when the server is someone else's. */++ private fun link(params: Map<String, String>, accepted: String): PairLink {++ val named = params["network"]?.takeIf { it.isNotBlank() }++ if (named != null && !Servers.same(named, accepted)) {++ return PairLink(code = null, network = accepted, refusedServer = Servers.normalize(named)?.let { hostOf(it) } ?: named)++ }++ return PairLink(++ code = params["code"]?.takeIf { it.isNotBlank() },++ network = accepted,++ pairing = params["pairing"]?.takeIf { it.startsWith("pair_") },++ )++ }++++ private fun hostOf(url: String): String {++ val rest = url.substringAfter("://", missingDelimiterValue = "")++ return rest.substringBefore('/').substringBefore(':').ifBlank { url }++ }++++ private fun queryParams(query: String): Map<String, String> {++ if (query.isBlank()) return emptyMap()++ return query.split('&').mapNotNull { part ->++ if (part.isBlank()) return@mapNotNull null++ val key = part.substringBefore('=')++ val value = part.substringAfter('=', missingDelimiterValue = "")++ if (key.isBlank()) return@mapNotNull null++ URLDecoder.decode(key, "UTF-8") to URLDecoder.decode(value, "UTF-8")++ }.toMap()++ }++++ fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")++}nnew file mode 100644@@@ -0,0 +1,36 @@++package network.openvibe.link.data++++import java.security.MessageDigest++import java.security.SecureRandom++import java.util.Base64++++/**++ * PKCE (RFC 7636, S256) for the sign-in. The phone is a public OAuth client: it holds no secret, and the verifier it++ * generates here is the only proof that the code it redeems is its own. Kept away from Android so it has tests.++ */++object Pkce {++ private val random = SecureRandom()++++ /** 43 characters of base64url without padding, from 32 random bytes. */++ fun verifier(): String {++ val bytes = ByteArray(32)++ random.nextBytes(bytes)++ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)++ }++++ fun challenge(verifier: String): String {++ val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))++ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)++ }++++ /** A random `state`, so a code that comes back from another request is refused. */++ fun state(): String {++ val bytes = ByteArray(16)++ random.nextBytes(bytes)++ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)++ }++++ /** The shape the server accepts (PKCE_RE): 43–128 unreserved characters. */++ fun isVerifier(value: String?): Boolean =++ value != null && value.length in 43..128 && value.all { it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == '~' }++}nnew file mode 100644@@@ -0,0 +1,34 @@++package network.openvibe.link.data++++import android.net.Uri++import network.openvibe.link.BuildConfig++++/**++ * Where this build of the app signs in and pairs. It is a build constant, never the QR's or the person's choice: a code++ * and a redirect belong to one server, and a link naming another is refused (PairLinks).++ *++ * A release build always carries https://openvibe.network. A debug build may be pointed elsewhere when it is built++ * (`./gradlew :app:assembleDebug -Pnetwork.url=https://…`); that build says so in a banner on every screen, so a++ * screenshot or a bug report can never be mistaken for production.++ */++object Servers {++ const val OFFICIAL = "https://openvibe.network"++++ /** The server this build talks to, with no trailing slash. */++ val network: String get() = BuildConfig.NETWORK_URL.trimEnd('/')++++ /** True in a debug build that was pointed at another server: what the banner shows. */++ val isDebugOverride: Boolean get() = BuildConfig.DEBUG_SERVER && network != OFFICIAL++++ /** The host, for a sentence ("… points at evil.test"). */++ val host: String get() = Uri.parse(network).host ?: network++++ /** Two server URLs are the same when they differ only by case, slashes or an empty path. */++ fun same(a: String?, b: String?): Boolean =++ normalize(a) != null && normalize(a) == normalize(b)++++ fun normalize(url: String?): String? {++ val v = url?.trim()?.trimEnd('/') ?: return null++ return if (v.startsWith("https://", ignoreCase = true)) v.lowercase() else null++ }++}nnew file mode 100644@@@ -0,0 +1,151 @@++package network.openvibe.link.data++++import android.content.Context++import android.content.Intent++import android.net.Uri++import androidx.browser.customtabs.CustomTabsIntent++import kotlinx.coroutines.Dispatchers++import kotlinx.coroutines.withContext++import okhttp3.FormBody++import okhttp3.OkHttpClient++import okhttp3.Request++import org.json.JSONObject++import java.util.concurrent.TimeUnit++++/**++ * The person's own OpenVibe.Network session, for the Approvals and Activity screens and for sending files to their++ * Download drive. It is a public OAuth client (`link`): authorization code + PKCE S256, no secret in the APK, and the++ * tokens are encrypted with the Android Keystore (Store).++ *++ * The code comes back on `https://openvibe.network/link/auth`, an Android App Link claimed for this package by++ * Network's /.well-known/assetlinks.json: a custom scheme could be answered by any app, this one only by us. The++ * verifier never leaves the phone except in the exchange it belongs to, and no token is ever logged.++ */++object Session {++ private const val CLIENT_ID = "link"++ const val REDIRECT_URI = "https://openvibe.network/link/auth"++ private val AUTHORIZE: String get() = "${Api.NETWORK}/oauth/authorize"++ private val TOKEN: String get() = "${Api.NETWORK}/oauth/token"++ private const val SCOPE = "profile theme"++++ private val client = OkHttpClient.Builder()++ .connectTimeout(15, TimeUnit.SECONDS)++ .readTimeout(30, TimeUnit.SECONDS)++ .build()++++ private var verifier: String? = null++ private var state: String? = null++++ /** The verifier this app is waiting to redeem; null when no sign-in is in flight. */++ val pendingVerifier: String? get() = verifier++++ /** Open the account chooser in a Custom Tab. Returns false when no browser answered. */++ fun signIn(context: Context): Boolean {++ val v = Pkce.verifier()++ val s = Pkce.state()++ verifier = v++ state = s++ val url = Uri.parse(AUTHORIZE).buildUpon()++ .appendQueryParameter("response_type", "code")++ .appendQueryParameter("client_id", CLIENT_ID)++ .appendQueryParameter("redirect_uri", REDIRECT_URI)++ .appendQueryParameter("scope", SCOPE)++ .appendQueryParameter("state", s)++ .appendQueryParameter("code_challenge", Pkce.challenge(v))++ .appendQueryParameter("code_challenge_method", "S256")++ .build()++ return try {++ CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, url)++ true++ } catch (e: Exception) {++ try {++ context.startActivity(Intent(Intent.ACTION_VIEW, url).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))++ true++ } catch (e2: Exception) {++ false++ }++ }++ }++++ /** Redeem the code the browser returned. Throws with what went wrong; the caller shows it. */++ suspend fun complete(uri: Uri): Unit = withContext(Dispatchers.IO) {++ val error = uri.getQueryParameter("error")++ if (error != null) throw IllegalStateException(++ if (error == "access_denied") "Sign-in was cancelled." else "Sign-in failed: $error",++ )++ val code = uri.getQueryParameter("code") ?: throw IllegalStateException("Sign-in returned no code.")++ val returned = uri.getQueryParameter("state")++ val expected = state++ val v = verifier++ if (expected == null || returned != expected) throw IllegalStateException("Sign-in returned for another request.")++ if (!Pkce.isVerifier(v)) throw IllegalStateException("Sign-in lost its verifier; try again.")++++ val body = FormBody.Builder()++ .add("grant_type", "authorization_code")++ .add("client_id", CLIENT_ID)++ .add("redirect_uri", REDIRECT_URI)++ .add("code", code)++ .add("code_verifier", v!!)++ .build()++ val request = Request.Builder().url(TOKEN).post(body).header("Accept", "application/json").build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (!response.isSuccessful) throw IllegalStateException(problem(text, response.code))++ val json = JSONObject(text)++ val access = json.optString("access_token")++ if (access.isEmpty()) throw IllegalStateException("Sign-in returned no token.")++ val expiresIn = json.optLong("expires_in", 3600L)++ Store.saveTokens(access, json.optString("refresh_token").takeIf { it.isNotEmpty() }, System.currentTimeMillis() + expiresIn * 1000)++ }++ verifier = null++ state = null++ }++++ /** A usable access token, refreshed when it is close to its expiry. Null means sign in again. */++ suspend fun token(): String? = withContext(Dispatchers.IO) {++ val access = Store.accessToken()++ val expires = Store.expiresAt()++ if (access != null && System.currentTimeMillis() < expires - 60_000) return@withContext access++ val refresh = Store.refreshToken() ?: return@withContext access.takeIf { System.currentTimeMillis() < expires }++ val body = FormBody.Builder()++ .add("grant_type", "refresh_token")++ .add("client_id", CLIENT_ID)++ .add("refresh_token", refresh)++ .build()++ val request = Request.Builder().url(TOKEN).post(body).header("Accept", "application/json").build()++ try {++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (!response.isSuccessful) {++ if (response.code == 400 || response.code == 401) Store.clearTokens()++ return@withContext null++ }++ val json = JSONObject(text)++ val next = json.optString("access_token").takeIf { it.isNotEmpty() } ?: return@withContext null++ val expiresIn = json.optLong("expires_in", 3600L)++ Store.saveTokens(next, json.optString("refresh_token").takeIf { it.isNotEmpty() } ?: refresh, System.currentTimeMillis() + expiresIn * 1000)++ next++ }++ } catch (e: Exception) {++ null++ }++ }++++ fun signOut() {++ verifier = null++ state = null++ Store.clearTokens()++ }++++ /** A token is held (it may still need a refresh; [token] does that). */++ fun hasSession(): Boolean = Store.accessToken() != null++++ private fun problem(text: String, status: Int): String {++ val detail = try {++ JSONObject(text).optString("error_description").takeIf { it.isNotEmpty() }++ } catch (e: Exception) {++ null++ }++ return detail ?: "Sign-in was refused ($status)."++ }++}nnew file mode 100644@@@ -0,0 +1,133 @@++package network.openvibe.link.data++++import android.content.Context++import android.content.SharedPreferences++import android.security.keystore.KeyGenParameterSpec++import android.security.keystore.KeyProperties++import android.util.Base64++import androidx.compose.runtime.getValue++import androidx.compose.runtime.mutableStateOf++import androidx.compose.runtime.setValue++import java.security.KeyStore++import javax.crypto.Cipher++import javax.crypto.KeyGenerator++import javax.crypto.SecretKey++import javax.crypto.spec.GCMParameterSpec++++/**++ * Everything secret the app keeps, encrypted with a key the Android Keystore makes and never exports: the pairing's++ * node credential, the session's access and refresh tokens. Losing the key (a cleared Keystore, a restore onto++ * another phone) loses the secrets: the person pairs again, which is the right answer. Nothing secret is ever written++ * to a log or to a crash report, and this app's data is excluded from Android backups.++ */++object Store {++ private const val PREFS = "openvibe-link"++ private const val KEY_ALIAS = "openvibe-link-secrets"++ private const val TRANSFORMATION = "AES/GCM/NoPadding"++ private const val GCM_TAG_BITS = 128++++ private lateinit var prefs: SharedPreferences++++ /** The pairing JSON the Go core reads; null when this phone is not paired. Compose observes it. */++ var paired: String? by mutableStateOf(null)++ private set++++ /** True when a Network session (the person's own token) is held. */++ var signedIn: Boolean by mutableStateOf(false)++ private set++++ /** The "Stay connected" switch, remembered across launches. */++ var stayConnected: Boolean by mutableStateOf(false)++ private set++++ var deviceLabel: String by mutableStateOf("")++ private set++++ fun init(context: Context) {++ prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)++ stayConnected = prefs.getBoolean("stay_connected", false)++ deviceLabel = prefs.getString("device_label", "").orEmpty()++ paired = read("pairing")++ signedIn = !read("access").isNullOrEmpty()++ }++++ @Synchronized++ fun savePairing(json: String, label: String) {++ write("pairing", json)++ deviceLabel = label++ prefs.edit().putString("device_label", label).apply()++ paired = json++ }++++ @Synchronized++ fun forgetPairing() {++ prefs.edit().remove(encryptedKey("pairing")).remove("device_label").apply()++ paired = null++ deviceLabel = ""++ }++++ @Synchronized++ fun saveTokens(access: String, refresh: String?, expiresAt: Long) {++ write("access", access)++ refresh?.let { write("refresh", it) }++ prefs.edit().putLong("expires_at", expiresAt).apply()++ signedIn = true++ }++++ @Synchronized++ fun clearTokens() {++ prefs.edit().remove(encryptedKey("access")).remove(encryptedKey("refresh")).remove("expires_at").apply()++ signedIn = false++ }++++ fun accessToken(): String? = read("access")++++ fun refreshToken(): String? = read("refresh")++++ fun expiresAt(): Long = prefs.getLong("expires_at", 0L)++++ fun keepConnected(on: Boolean) {++ stayConnected = on++ prefs.edit().putBoolean("stay_connected", on).apply()++ }++++ private fun encryptedKey(name: String) = "enc_$name"++++ private fun write(name: String, value: String) {++ val cipher = Cipher.getInstance(TRANSFORMATION)++ cipher.init(Cipher.ENCRYPT_MODE, key())++ val sealed = cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))++ prefs.edit().putString(encryptedKey(name), Base64.encodeToString(sealed, Base64.NO_WRAP)).apply()++ }++++ private fun read(name: String): String? {++ val stored = prefs.getString(encryptedKey(name), null) ?: return null++ return try {++ val sealed = Base64.decode(stored, Base64.NO_WRAP)++ val iv = sealed.copyOfRange(0, 12)++ val body = sealed.copyOfRange(12, sealed.size)++ val cipher = Cipher.getInstance(TRANSFORMATION)++ cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(GCM_TAG_BITS, iv))++ String(cipher.doFinal(body), Charsets.UTF_8)++ } catch (e: Exception) {++ // A restored backup or a cleared Keystore: the secret is gone, and the person pairs again.++ null++ }++ }++++ private fun key(): SecretKey {++ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }++ (store.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }++ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")++ val spec = KeyGenParameterSpec.Builder(++ KEY_ALIAS,++ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,++ )++ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)++ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)++ .setKeySize(256)++ .build()++ generator.init(spec)++ return generator.generateKey()++ }++}nnew file mode 100644@@@ -0,0 +1,151 @@++package network.openvibe.link.service++++import android.app.NotificationChannel++import android.app.NotificationManager++import android.app.PendingIntent++import android.app.Service++import android.content.Context++import android.content.Intent++import android.net.Uri++import android.os.IBinder++import androidx.core.app.NotificationCompat++import androidx.core.content.ContextCompat++import kotlinx.coroutines.CoroutineScope++import kotlinx.coroutines.Dispatchers++import kotlinx.coroutines.SupervisorJob++import kotlinx.coroutines.cancel++import kotlinx.coroutines.launch++import network.openvibe.link.MainActivity++import network.openvibe.link.R++import network.openvibe.link.share.ShareUploader++import java.util.concurrent.atomic.AtomicInteger++++/**++ * The share sheet's upload, as a foreground service with progress: the person can leave the app while a photo or a++ * file goes to their Download drive, and the notification says where it is.++ */++class ShareUploadService : Service() {++ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)++++ override fun onBind(intent: Intent?): IBinder? = null++++ override fun onCreate() {++ super.onCreate()++ ensureChannel(this)++ }++++ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {++ val uris = intent?.getStringArrayListExtra(EXTRA_URIS).orEmpty()++ val mime = intent?.getStringExtra(EXTRA_MIME).orEmpty().ifBlank { "application/octet-stream" }++ val names = intent?.getStringArrayListExtra(EXTRA_NAMES).orEmpty()++ StayConnectedService.startForegroundCompat(this, NOTIFICATION_ID, progress("Preparing…", 0, 1))++ if (uris.isEmpty()) {++ stopSelf(startId)++ return START_NOT_STICKY++ }++ scope.launch {++ var sent = 0++ var failed = 0++ uris.forEachIndexed { index, raw ->++ val uri = Uri.parse(raw)++ try {++ val result = ShareUploader.send(this@ShareUploadService, uri, mime) { done, total ->++ notify(progress("Sending ${index + 1} of ${uris.size} · ${percent(done, total)}", index + 1, uris.size))++ }++ sent++++ notify(progress("Sent ${result.name}", index + 1, uris.size))++ } catch (e: Exception) {++ failed++++ notify(progress("Could not send ${names.getOrNull(index) ?: "a file"}: ${e.message ?: "error"}", index + 1, uris.size))++ }++ }++ val title = when {++ failed == 0 -> if (sent == 1) "Sent to OpenVibe" else "$sent files sent to OpenVibe"++ sent == 0 -> "Nothing was sent"++ else -> "$sent sent, $failed failed"++ }++ done(title, failed == 0)++ stopForeground(STOP_FOREGROUND_DETACH)++ stopSelf(startId)++ }++ return START_NOT_STICKY++ }++++ override fun onDestroy() {++ scope.cancel()++ super.onDestroy()++ }++++ private fun progress(text: String, done: Int, total: Int): android.app.Notification {++ val open = PendingIntent.getActivity(++ this, 0, Intent(this, MainActivity::class.java),++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,++ )++ return NotificationCompat.Builder(this, CHANNEL)++ .setSmallIcon(R.drawable.ic_notify)++ .setContentTitle("OpenVibe · Download")++ .setContentText(text)++ .setProgress(total, done, false)++ .setOngoing(true)++ .setOnlyAlertOnce(true)++ .setContentIntent(open)++ .setCategory(NotificationCompat.CATEGORY_PROGRESS)++ .setPriority(NotificationCompat.PRIORITY_LOW)++ .build()++ }++++ private fun done(text: String, ok: Boolean): android.app.Notification {++ val open = PendingIntent.getActivity(++ this, 0, Intent(this, MainActivity::class.java),++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,++ )++ return NotificationCompat.Builder(this, CHANNEL)++ .setSmallIcon(R.drawable.ic_notify)++ .setContentTitle(text)++ .setContentText(if (ok) "In your Download drive on openvibe.download" else "Open the app to try again")++ .setAutoCancel(true)++ .setContentIntent(open)++ .setPriority(NotificationCompat.PRIORITY_DEFAULT)++ .build()++ }++++ private fun notify(notification: android.app.Notification) {++ getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification)++ }++++ private fun percent(done: Long, total: Long): String {++ if (total <= 0) return ""++ return "${(done * 100 / total).coerceIn(0, 100)}%"++ }++++ companion object {++ const val CHANNEL = "downloads"++ private const val NOTIFICATION_ID = 1002++ const val EXTRA_URIS = "network.openvibe.link.URIS"++ const val EXTRA_NAMES = "network.openvibe.link.NAMES"++ const val EXTRA_MIME = "network.openvibe.link.MIME"++++ private val nextId = AtomicInteger(1)++++ fun ensureChannel(context: Context) {++ val manager = context.getSystemService(NotificationManager::class.java) ?: return++ if (manager.getNotificationChannel(CHANNEL) == null) {++ manager.createNotificationChannel(++ NotificationChannel(CHANNEL, context.getString(R.string.channel_downloads), NotificationManager.IMPORTANCE_LOW).apply {++ description = "Files you send to your Download drive"++ setShowBadge(false)++ },++ )++ }++ }++++ fun start(context: Context, uris: List<String>, names: List<String>, mime: String) {++ ensureChannel(context)++ val intent = Intent(context, ShareUploadService::class.java)++ .putStringArrayListExtra(EXTRA_URIS, ArrayList(uris))++ .putStringArrayListExtra(EXTRA_NAMES, ArrayList(names))++ .putExtra(EXTRA_MIME, mime)++ ContextCompat.startForegroundService(context, intent)++ }++ }++}nnew file mode 100644@@@ -0,0 +1,136 @@++package network.openvibe.link.service++++import android.app.Notification++import android.app.NotificationChannel++import android.app.NotificationManager++import android.app.PendingIntent++import android.app.Service++import android.content.Context++import android.content.Intent++import android.content.pm.ServiceInfo++import android.os.Build++import android.os.Handler++import android.os.IBinder++import android.os.Looper++import androidx.core.app.NotificationCompat++import androidx.core.content.ContextCompat++import network.openvibe.link.MainActivity++import network.openvibe.link.R++import network.openvibe.link.data.LinkController++import network.openvibe.link.data.Store++++/**++ * "Stay connected": the phone's one outbound connection to OpenVibe.Bot, held by an opt-in foreground service with a++ * notification the person can always see and stop (the same rule as the plan's "always visible", without any phone++ * automation). Nothing else runs here: no camera, no screen capture, no accessibility.++ */++class StayConnectedService : Service() {++ private val main = Handler(Looper.getMainLooper())++ private var tick: Runnable? = null++++ override fun onBind(intent: Intent?): IBinder? = null++++ override fun onCreate() {++ super.onCreate()++ ensureChannel(this)++ }++++ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {++ if (intent?.action == ACTION_STOP) {++ LinkController.stop()++ Store.keepConnected(false)++ stopSelf()++ return START_NOT_STICKY++ }++ Store.keepConnected(true)++ startForegroundCompat(this, NOTIFICATION_ID, notification("Connecting to OpenVibe…"))++ LinkController.start()++ watch()++ return START_STICKY++ }++++ override fun onDestroy() {++ tick?.let { main.removeCallbacks(it) }++ tick = null++ if (!Store.stayConnected) LinkController.stop()++ super.onDestroy()++ }++++ private fun watch() {++ tick?.let { main.removeCallbacks(it) }++ val runnable = object : Runnable {++ override fun run() {++ val text = when {++ LinkController.connected -> "Connected as ${Store.deviceLabel.ifBlank { "this phone" }}"++ LinkController.state == "revoked" -> "This phone was unpaired: pair it again"++ else -> "Offline. Reconnecting…"++ }++ val manager = getSystemService(NotificationManager::class.java)++ manager?.notify(NOTIFICATION_ID, notification(text))++ if (LinkController.state != "revoked") main.postDelayed(this, 5_000)++ }++ }++ tick = runnable++ main.post(runnable)++ }++++ private fun notification(text: String): Notification {++ val open = PendingIntent.getActivity(++ this, 0, Intent(this, MainActivity::class.java),++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,++ )++ val stop = PendingIntent.getService(++ this, 1, Intent(this, StayConnectedService::class.java).setAction(ACTION_STOP),++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,++ )++ return NotificationCompat.Builder(this, CHANNEL)++ .setSmallIcon(R.drawable.ic_notify)++ .setContentTitle("OpenVibe Link")++ .setContentText(text)++ .setOngoing(true)++ .setOnlyAlertOnce(true)++ .setContentIntent(open)++ .addAction(0, "Stop", stop)++ .setCategory(NotificationCompat.CATEGORY_SERVICE)++ .setPriority(NotificationCompat.PRIORITY_LOW)++ .build()++ }++++ companion object {++ const val ACTION_START = "network.openvibe.link.STAY_START"++ const val ACTION_STOP = "network.openvibe.link.STAY_STOP"++ const val CHANNEL = "stay_connected"++ private const val NOTIFICATION_ID = 1001++++ fun ensureChannel(context: Context) {++ val manager = context.getSystemService(NotificationManager::class.java) ?: return++ if (manager.getNotificationChannel(CHANNEL) == null) {++ manager.createNotificationChannel(++ NotificationChannel(CHANNEL, context.getString(R.string.channel_stay_connected), NotificationManager.IMPORTANCE_LOW).apply {++ description = "The phone's connection to OpenVibe"++ setShowBadge(false)++ },++ )++ }++ }++++ fun start(context: Context) {++ ensureChannel(context)++ val intent = Intent(context, StayConnectedService::class.java).setAction(ACTION_START)++ ContextCompat.startForegroundService(context, intent)++ }++++ fun stop(context: Context) {++ context.startService(Intent(context, StayConnectedService::class.java).setAction(ACTION_STOP))++ context.stopService(Intent(context, StayConnectedService::class.java))++ }++++ fun startForegroundCompat(service: Service, id: Int, notification: Notification) {++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {++ service.startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)++ } else {++ service.startForeground(id, notification)++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,50 @@++package network.openvibe.link.share++++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.padding++import androidx.compose.foundation.layout.width++import androidx.compose.material3.Button++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.Surface++import androidx.compose.material3.Text++import androidx.compose.material3.TextButton++import androidx.compose.runtime.Composable++import androidx.compose.ui.Alignment++import androidx.compose.ui.Modifier++import androidx.compose.ui.unit.dp++++/**++ * The share sheet's question, asked before anything leaves the phone: a share intent is another app's request, not the++ * person's decision. Send starts the upload; Cancel closes the sheet and the source app comes back with the file++ * untouched.++ */++@Composable++fun ConfirmShare(request: ShareRequest, onSend: () -> Unit, onCancel: () -> Unit) {++ Surface(color = MaterialTheme.colorScheme.background) {++ Column(Modifier.fillMaxWidth().padding(24.dp)) {++ Text(request.question, style = MaterialTheme.typography.headlineSmall)++ Text(++ "It goes to your private Download drive, as you. Nothing is re-encoded.",++ style = MaterialTheme.typography.bodyMedium,++ color = MaterialTheme.colorScheme.onSurfaceVariant,++ modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),++ )++ request.text?.let {++ Text(++ it.take(140),++ style = MaterialTheme.typography.bodySmall,++ color = MaterialTheme.colorScheme.onSurfaceVariant,++ modifier = Modifier.padding(bottom = 20.dp),++ )++ }++ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {++ Button(onClick = onSend) { Text("Send") }++ Spacer(Modifier.width(12.dp))++ TextButton(onClick = onCancel) { Text("Cancel") }++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,56 @@++package network.openvibe.link.share++++/**++ * What a share sheet target received, without an Android type in it so the reading rules can be tested on the JVM.++ *++ * `uris` are content URIs as strings (the app turns them back into Uri when it opens them). `text` is the pasted++ * text of a text share; a share with both files and text sends the files and keeps the text as the name.++ */++data class ShareRequest(++ val mimeType: String,++ val uris: List<String>,++ val text: String?,++) {++ val isMultiple: Boolean get() = uris.size > 1++ val hasFiles: Boolean get() = uris.isNotEmpty()++++ /** The question the sheet asks before anything leaves the phone. */++ val question: String get() = when {++ isMultiple -> "Send these ${uris.size} files to your Download?"++ hasFiles -> "Send this to your Download?"++ else -> "Send this text to your Download?"++ }++}++++object ShareIntents {++ const val ACTION_SEND = "android.intent.action.SEND"++ const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"++ const val FALLBACK_MIME = "application/octet-stream"++ const val TEXT_MIME = "text/plain"++++ /**++ * Read one share. Null means there is nothing to send (or it is not a share at all), so the app can say so++ * instead of opening an empty upload.++ */++ fun parse(action: String?, mimeType: String?, uris: List<String>, text: String?): ShareRequest? {++ val multiple = when (action) {++ ACTION_SEND -> false++ ACTION_SEND_MULTIPLE -> true++ else -> return null++ }++ val files = uris.map { it.trim() }.filter { it.isNotEmpty() }.distinct()++ val pasted = text?.trim()?.takeIf { it.isNotEmpty() }++ if (files.isEmpty() && pasted == null) return null++++ val mime = mimeType?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "*/*" }++ ?: if (files.isEmpty()) TEXT_MIME else FALLBACK_MIME++ val chosen = if (multiple) files else files.take(1)++ return ShareRequest(mimeType = mime, uris = chosen, text = pasted)++ }++++ /** A pasted text has no file name: the first line, clipped, is the file's name. */++ fun nameFor(text: String?): String? {++ val first = text?.lineSequence()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null++ return (first.take(60) + if (first.length > 60) "…" else "") + ".txt"++ }++}nnew file mode 100644@@@ -0,0 +1,174 @@++package network.openvibe.link.share++++import android.content.Context++import android.net.Uri++import android.provider.OpenableColumns++import kotlinx.coroutines.Dispatchers++import kotlinx.coroutines.withContext++import network.openvibe.link.data.Api++import network.openvibe.link.data.ApiException++import network.openvibe.link.data.Session++import okhttp3.MediaType.Companion.toMediaType++import okhttp3.OkHttpClient++import okhttp3.Request++import okhttp3.RequestBody.Companion.toRequestBody++import org.json.JSONObject++import java.io.InputStream++import java.util.concurrent.TimeUnit++++/**++ * "Send to OpenVibe": the drive's own upload, the same three steps the web page makes to OpenVibe.MediaHub++ * (POST /api/v1/files → PUT /api/v1/uploads/:id/parts/:n → POST …/complete), with the person's Network token as the++ * Bearer. The bytes go straight from the phone: nothing is re-encoded, and the file is private until the person++ * shares it on openvibe.download.++ */++object ShareUploader {++ private val client = OkHttpClient.Builder()++ .connectTimeout(15, TimeUnit.SECONDS)++ .writeTimeout(120, TimeUnit.SECONDS)++ .readTimeout(60, TimeUnit.SECONDS)++ .build()++++ private val JSON = "application/json; charset=utf-8".toMediaType()++ private val OCTETS = "application/octet-stream".toMediaType()++++ class Sent(val fileId: String?, val name: String)++++ /** Size and display name of a shared content Uri, as the system reports them. */++ fun describe(context: Context, uri: Uri): Pair<Long, String> {++ var name = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "file"++ var size = -1L++ try {++ context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c ->++ if (c.moveToFirst()) {++ val nameIndex = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)++ val sizeIndex = c.getColumnIndex(OpenableColumns.SIZE)++ if (nameIndex >= 0) name = c.getString(nameIndex)?.takeIf { it.isNotBlank() } ?: name++ if (sizeIndex >= 0 && !c.isNull(sizeIndex)) size = c.getLong(sizeIndex)++ }++ }++ } catch (e: Exception) {++ // A provider that refuses metadata: the stream's own size is asked for below.++ }++ if (size <= 0) {++ size = try {++ context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } ?: -1L++ } catch (e: Exception) {++ -1L++ }++ }++ if (size <= 0) throw ApiException("This file could not be read.")++ return size to name++ }++++ suspend fun send(++ context: Context,++ uri: Uri,++ mimeType: String,++ onProgress: (sent: Long, total: Long) -> Unit,++ ): Sent = withContext(Dispatchers.IO) {++ val token = Session.token() ?: throw ApiException("Sign in to OpenVibe to send files.")++ val (size, name) = describe(context, uri)++ val upload = start(token, name, size, mimeType)++++ val input = context.contentResolver.openInputStream(uri)++ ?: throw ApiException("This file could not be opened.")++ input.use { stream ->++ var sent = 0L++ for (part in 1..upload.parts) {++ val length = if (part < upload.parts) upload.partSize else (size - upload.partSize * (upload.parts - 1))++ val bytes = readExactly(stream, length.toInt())++ if (bytes.size != length.toInt()) throw ApiException("The file changed while it was being sent.")++ putPart(token, upload.id, part, bytes)++ sent += length++ onProgress(sent, size)++ }++ }++ val fileId = complete(token, upload.id)++ Sent(fileId, name)++ }++++ private class Started(val id: String, val partSize: Long, val parts: Long)++++ private fun start(token: String, name: String, size: Long, mimeType: String): Started {++ val body = JSONObject()++ .put("name", name)++ .put("size", size)++ .put("content_type", mimeType.ifBlank { "application/octet-stream" })++ .toString()++ .toRequestBody(JSON)++ val request = Request.Builder()++ .url("${Api.DOWNLOAD}/api/v1/files")++ .header("Authorization", "Bearer $token")++ .header("Accept", "application/json")++ .post(body)++ .build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (response.code == 401) throw ApiException("Your session ended. Sign in again.")++ if (!response.isSuccessful) throw ApiException(problem(text) ?: "The drive refused the upload (${response.code}).")++ val upload = JSONObject(text).optJSONObject("upload")++ ?: throw ApiException("The drive answered no upload.")++ val id = upload.optString("id")++ if (id.isEmpty()) throw ApiException("The drive answered no upload.")++ return Started(id, upload.optLong("part_size", 8 * 1024 * 1024L), upload.optLong("parts_expected", 1L))++ }++ }++++ private fun putPart(token: String, uploadId: String, part: Long, bytes: ByteArray) {++ val request = Request.Builder()++ .url("${Api.DOWNLOAD}/api/v1/uploads/$uploadId/parts/$part")++ .header("Authorization", "Bearer $token")++ .put(bytes.toRequestBody(OCTETS))++ .build()++ client.newCall(request).execute().use { response ->++ if (!response.isSuccessful) {++ val text = response.body?.string().orEmpty()++ throw ApiException(problem(text) ?: "A part of the file was refused (${response.code}).")++ }++ }++ }++++ private fun complete(token: String, uploadId: String): String? {++ val request = Request.Builder()++ .url("${Api.DOWNLOAD}/api/v1/uploads/$uploadId/complete")++ .header("Authorization", "Bearer $token")++ .header("Accept", "application/json")++ .post("{}".toRequestBody(JSON))++ .build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (!response.isSuccessful) throw ApiException(problem(text) ?: "The drive could not finish the upload (${response.code}).")++ return try {++ JSONObject(text).optJSONObject("file")?.optString("id")?.takeIf { it.isNotEmpty() }++ } catch (e: Exception) {++ null++ }++ }++ }++++ private fun readExactly(stream: InputStream, length: Int): ByteArray {++ val out = ByteArray(length)++ var read = 0++ while (read < length) {++ val n = stream.read(out, read, length - read)++ if (n < 0) break++ read += n++ }++ return if (read == length) out else out.copyOf(read)++ }++++ private fun problem(text: String): String? = try {++ JSONObject(text).optString("detail").takeIf { it.isNotEmpty() }++ } catch (e: Exception) {++ null++ }++++ /** A pasted text becomes a small file in the app's cache, named after its first line. */++ fun textFile(context: Context, text: String): Pair<Uri, String> {++ val name = ShareIntents.nameFor(text) ?: "note.txt"++ val file = java.io.File(context.cacheDir, "share-${System.currentTimeMillis()}-$name")++ file.writeText(text)++ return Uri.fromFile(file) to name++ }++}nnew file mode 100644@@@ -0,0 +1,92 @@++package network.openvibe.link.ui++++import androidx.compose.foundation.layout.Arrangement++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.Row++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.lazy.items++import androidx.compose.material.icons.Icons++import androidx.compose.material.icons.outlined.History++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.Text++import androidx.compose.runtime.Composable++import androidx.compose.runtime.LaunchedEffect++import androidx.compose.ui.Alignment++import androidx.compose.ui.Modifier++import androidx.compose.ui.text.font.FontWeight++import androidx.compose.ui.unit.dp++import network.openvibe.link.data.ActionItem++import network.openvibe.link.data.Store++++/** The words and the dot for each bucket of the Action Center. */++private fun bucketLook(bucket: String): Pair<String, Tone> = when (bucket.lowercase()) {++ "running", "active" -> "Running" to Tone.Ok++ "waiting", "pending" -> "Waiting for you" to Tone.Warn++ "scheduled", "later" -> "Scheduled" to Tone.Neutral++ "failed", "error" -> "Did not finish" to Tone.Bad++ "done", "finished", "succeeded" -> "Done" to Tone.Info++ else -> bucket.replaceFirstChar { it.uppercase() } to Tone.Neutral++}++++/** The order the groups read in: what is happening now first, what is done last. */++private val ORDER = listOf("running", "active", "waiting", "pending", "scheduled", "later", "failed", "error", "done", "finished", "succeeded")++++/** The Dock's timeline, read-only: everything your agents are doing, from the Action Center, grouped by state. */++@Composable++fun ActivityScreen(preview: List<ActionItem>? = null) {++ LaunchedEffect(Store.signedIn) {++ if (preview == null && Store.signedIn) ActionsRepo.refresh()++ }++++ if (preview == null && !Store.signedIn) {++ SignInCard("Activity")++ return++ }++++ val items = preview ?: ActionsRepo.activity++ val firstLoad = preview == null && ActionsRepo.loading && ActionsRepo.doc == null++ val groups = items.groupBy { it.bucket.lowercase() }.toList()++ .sortedBy { (b, _) -> ORDER.indexOf(b).let { if (it < 0) ORDER.size else it } }++++ LinkPage(title = "Activity", lead = "What your agents are running, waiting on and finished. Answer approvals on their own tab.") {++ ActionsRepo.error?.let { err ->++ item { Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }++ }++ if (firstLoad) {++ skeletonCards(2)++ return@LinkPage++ }++ if (items.isEmpty()) {++ item { EmptyState(Icons.Outlined.History, "Nothing yet", "When an agent starts a task for you, it shows here as it runs, with what it is waiting on.") }++ return@LinkPage++ }++ groups.forEach { (bucket, rows) ->++ val (label, tone) = bucketLook(bucket)++ item(key = "h-$bucket") {++ Row(Modifier.padding(top = 10.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {++ StatusDot(tone)++ Text("$label · ${rows.size}", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ }++ items(rows, key = { it.service + it.id + (it.updatedAt ?: "") }) { ActivityRow(it) }++ }++ }++}++++/** One thing an agent is doing: its sentence, the latest line, and who and when. */++@Composable++fun ActivityRow(item: ActionItem) {++ LinkCard {++ Text(item.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)++ item.summary?.takeIf { it.isNotBlank() && it != item.title }?.let {++ Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ val who = listOfNotNull(item.agent, (item.updatedAt ?: item.createdAt)?.let { shortTime(it) }).joinToString(" · ")++ if (who.isNotEmpty()) {++ Column(Modifier.fillMaxWidth()) {++ Text(who, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,130 @@++package network.openvibe.link.ui++++import android.net.Uri++import android.widget.Toast++import androidx.compose.foundation.background++import androidx.compose.foundation.layout.Box++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.fillMaxSize++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.padding++import androidx.compose.material.icons.Icons++import androidx.compose.material.icons.outlined.History++import androidx.compose.material.icons.outlined.Login++import androidx.compose.material.icons.outlined.PhoneAndroid++import androidx.compose.material.icons.outlined.TaskAlt++import androidx.compose.material3.Button++import androidx.compose.material3.Icon++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.NavigationBar++import androidx.compose.material3.NavigationBarItem++import androidx.compose.material3.Scaffold++import androidx.compose.material3.Text++import androidx.compose.runtime.Composable++import androidx.compose.runtime.LaunchedEffect++import androidx.compose.runtime.getValue++import androidx.compose.runtime.mutableIntStateOf++import androidx.compose.runtime.saveable.rememberSaveable++import androidx.compose.runtime.setValue++import androidx.compose.ui.Modifier++import androidx.compose.ui.platform.LocalContext++import androidx.compose.ui.unit.dp++import network.openvibe.link.data.Servers++import network.openvibe.link.data.Session++import network.openvibe.link.data.Store++++/** The app: pair first, then the three screens of a paired phone. */++@Composable++fun LinkApp() {++ val context = LocalContext.current++++ val authUri = Incoming.authUri++ LaunchedEffect(authUri) {++ val raw = authUri ?: return@LaunchedEffect++ try {++ Session.complete(Uri.parse(raw))++ Messages.show("Signed in.")++ ActionsRepo.refresh()++ } catch (e: Exception) {++ Messages.show(e.message ?: "Sign-in failed.")++ }++ Incoming.authUri = null++ }++++ LaunchedEffect(Messages.text) {++ val message = Messages.text ?: return@LaunchedEffect++ Toast.makeText(context, message, Toast.LENGTH_LONG).show()++ Messages.text = null++ }++++ Column(Modifier.fillMaxSize()) {++ // A debug build pointed at another server says so on every screen: nothing here is production.++ if (Servers.isDebugOverride) {++ Text(++ "Debug server · ${Servers.host}",++ style = MaterialTheme.typography.bodySmall,++ color = MaterialTheme.colorScheme.onTertiaryContainer,++ modifier = Modifier++ .fillMaxWidth()++ .background(MaterialTheme.colorScheme.tertiaryContainer)++ .padding(horizontal = 16.dp, vertical = 6.dp),++ )++ }++ Box(Modifier.weight(1f)) {++ if (Store.paired == null) {++ PairScreen(initialCode = Incoming.pairCode)++ } else {++ MainScreen()++ }++ }++ }++}++++@Composable++private fun MainScreen() {++ var tab by rememberSaveable { mutableIntStateOf(0) }++ val tabs = listOf("Approvals" to Icons.Outlined.TaskAlt, "Activity" to Icons.Outlined.History, "This phone" to Icons.Outlined.PhoneAndroid)++ Scaffold(++ bottomBar = {++ NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {++ tabs.forEachIndexed { index, (label, icon) ->++ NavigationBarItem(++ selected = tab == index,++ onClick = { tab = index },++ icon = { Icon(icon, contentDescription = null) },++ label = { Text(label) },++ )++ }++ }++ },++ ) { padding ->++ Box(Modifier.fillMaxSize().padding(padding)) {++ when (tab) {++ 0 -> ApprovalsScreen()++ 1 -> ActivityScreen()++ else -> PhoneScreen()++ }++ }++ }++}++++/** A session and a paired phone are both needed for the two read screens: one card, one button. */++@Composable++fun SignInCard(title: String = "Your OpenVibe account") {++ val context = LocalContext.current++ LinkPage(title = title, lead = null) {++ item {++ LinkCard {++ EmptyState(++ Icons.Outlined.Login,++ "Sign in to see what your agents need",++ "The phone keeps a token from OpenVibe.Network, sealed with a key only this phone's Keystore holds.",++ )++ Button(++ onClick = { if (!Session.signIn(context)) Messages.show("No browser answered. Install one and try again.") },++ modifier = Modifier.fillMaxWidth(),++ ) { Text("Sign in to OpenVibe") }++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,178 @@++package network.openvibe.link.ui++++import androidx.compose.foundation.layout.Arrangement++import androidx.compose.foundation.layout.Row++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.heightIn++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.layout.size++import androidx.compose.foundation.lazy.items++import androidx.compose.material.icons.Icons++import androidx.compose.material.icons.outlined.DeleteOutline++import androidx.compose.material.icons.outlined.Fingerprint++import androidx.compose.material.icons.outlined.Key++import androidx.compose.material.icons.outlined.Payments++import androidx.compose.material.icons.outlined.PersonAddAlt++import androidx.compose.material.icons.outlined.TaskAlt++import androidx.compose.material.icons.outlined.Tune++import androidx.compose.material3.Button++import androidx.compose.material3.Icon++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.OutlinedButton++import androidx.compose.material3.Text++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.rememberCoroutineScope++import androidx.compose.runtime.setValue++import androidx.compose.ui.Alignment++import androidx.compose.ui.Modifier++import androidx.compose.ui.graphics.vector.ImageVector++import androidx.compose.ui.platform.LocalContext++import androidx.compose.ui.text.font.FontWeight++import androidx.compose.ui.unit.dp++import androidx.fragment.app.FragmentActivity++import kotlinx.coroutines.launch++import network.openvibe.link.approval.ApprovalCategory++import network.openvibe.link.approval.ApprovalRules++import network.openvibe.link.approval.Biometrics++import network.openvibe.link.data.ActionItem++import network.openvibe.link.data.Store++++/** Each category's tone and icon: money amber, deleting red, passwords violet, a new recipient blue. */++private fun ApprovalCategory.tone(): Tone = when (this) {++ ApprovalCategory.MONEY -> Tone.Warn++ ApprovalCategory.DELETE -> Tone.Bad++ ApprovalCategory.PASSWORD -> Tone.Secret++ ApprovalCategory.NEW_RECIPIENT -> Tone.Info++ ApprovalCategory.OTHER -> Tone.Neutral++}++++private fun ApprovalCategory.icon(): ImageVector = when (this) {++ ApprovalCategory.MONEY -> Icons.Outlined.Payments++ ApprovalCategory.DELETE -> Icons.Outlined.DeleteOutline++ ApprovalCategory.PASSWORD -> Icons.Outlined.Key++ ApprovalCategory.NEW_RECIPIENT -> Icons.Outlined.PersonAddAlt++ ApprovalCategory.OTHER -> Icons.Outlined.Tune++}++++/**++ * Actor's pending approvals for the person. Approve or decline; money, passwords, deletions and a send to a new++ * recipient ask for a fingerprint, a face or the device credential first, and only then send the answer. Each card++ * leads with what it is about (a tinted chip), then the agent's sentence, then who asked and when, then the answer.++ */++@Composable++fun ApprovalsScreen(preview: List<ActionItem>? = null) {++ val context = LocalContext.current++ val activity = context as? FragmentActivity++ val scope = rememberCoroutineScope()++ var working by remember { mutableStateOf<String?>(null) }++++ LaunchedEffect(Store.signedIn) {++ if (preview == null && Store.signedIn) ActionsRepo.refresh()++ }++++ if (preview == null && !Store.signedIn) {++ SignInCard("Approvals")++ return++ }++++ val waiting = preview ?: ActionsRepo.approvals++ val firstLoad = preview == null && ActionsRepo.loading && ActionsRepo.doc == null++++ LinkPage(++ title = "Approvals",++ lead = when {++ firstLoad -> "Asking your agents what is waiting…"++ waiting.isEmpty() -> null++ waiting.size == 1 -> "One thing is waiting for your answer."++ else -> "${waiting.size} things are waiting for your answer."++ },++ ) {++ ActionsRepo.error?.let { err ->++ item { Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }++ }++ if (firstLoad) {++ skeletonCards(3)++ return@LinkPage++ }++ if (waiting.isEmpty()) {++ item {++ EmptyState(++ Icons.Outlined.TaskAlt,++ "Nothing is waiting for you",++ "When an agent wants to pay, delete, sign in or write to someone new, it asks here first.",++ )++ }++ return@LinkPage++ }++++ items(waiting, key = { it.service + it.id }) { item ->++ val category = ApprovalRules.classify(item.category, item.kind, item.title, item.summary)++ val decide: (Boolean) -> Unit = { approve ->++ val send = {++ working = item.id++ scope.launch {++ try {++ ActionsRepo.decide(item, approve)++ } catch (e: Exception) {++ Messages.show(e.message ?: "The answer was not sent.")++ } finally {++ working = null++ }++ }++ Unit++ }++ if (category.needsBiometrics && activity != null) {++ Biometrics.confirm(++ activity,++ "${if (approve) category.label else "Declining"} · ${item.title}",++ onSuccess = { send() },++ onError = { Messages.show(it) },++ )++ } else {++ send()++ }++ }++ LinkCard(accent = if (category.needsBiometrics) toneColor(category.tone()) else null) {++ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {++ ToneChip(category.label, category.tone(), category.icon())++ if (category.needsBiometrics) {++ Icon(Icons.Outlined.Fingerprint, contentDescription = "Asks for your fingerprint or face", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))++ }++ }++ Text(item.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)++ item.summary?.takeIf { it.isNotBlank() && it != item.title }?.let {++ Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ val who = listOfNotNull(item.agent, item.createdAt?.let { shortTime(it) }).joinToString(" · ")++ if (who.isNotEmpty()) {++ Text(who, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {++ Button(++ enabled = working == null,++ onClick = { decide(true) },++ modifier = Modifier.weight(1f).heightIn(min = 48.dp),++ ) { Text(if (working == item.id) "Sending…" else "Approve", fontWeight = FontWeight.Bold) }++ OutlinedButton(++ enabled = working == null,++ onClick = { decide(false) },++ modifier = Modifier.weight(1f).heightIn(min = 48.dp),++ ) { Text("Decline") }++ }++ }++ }++ }++}++++/** "2026-11-07T15:00:00.000Z" → "7 Nov 15:00". A value the formatter cannot read is shown as it came. */++fun shortTime(iso: String): String = try {++ val instant = java.time.Instant.parse(iso)++ val local = instant.atZone(java.time.ZoneId.systemDefault())++ java.time.format.DateTimeFormatter.ofPattern("d MMM HH:mm").format(local)++} catch (e: Exception) {++ iso.take(16).replace('T', ' ')++}nnew file mode 100644@@@ -0,0 +1,144 @@++package network.openvibe.link.ui++++import androidx.compose.foundation.BorderStroke++import androidx.compose.foundation.background++import androidx.compose.foundation.layout.Arrangement++import androidx.compose.foundation.layout.Box++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.ColumnScope++import androidx.compose.foundation.layout.PaddingValues++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.layout.width++import androidx.compose.foundation.lazy.LazyColumn++import androidx.compose.foundation.lazy.LazyListScope++import androidx.compose.foundation.shape.CircleShape++import androidx.compose.foundation.shape.RoundedCornerShape++import androidx.compose.material3.Icon++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.Surface++import androidx.compose.material3.Text++import androidx.compose.runtime.Composable++import androidx.compose.ui.Alignment++import androidx.compose.ui.Modifier++import androidx.compose.ui.graphics.Color++import androidx.compose.ui.graphics.vector.ImageVector++import androidx.compose.ui.text.font.FontWeight++import androidx.compose.ui.unit.dp++import androidx.compose.ui.unit.sp++++/*++ * Link's small kit (Opus, 11-07): the pieces every screen is made of, in OpenVibe's look — a page with its title and one++ * line under it, a card on the raised surface with a hairline, a chip whose tone says how much a thing matters, a++ * status dot, a still skeleton while the first answer loads (no spinning ring: the owner's rule), and an empty state++ * that says what will appear and why. Colours come only from the theme (LinkTheme).++ */++++/** How much a thing matters: the tone of a chip or a dot. */++enum class Tone { Neutral, Info, Ok, Warn, Bad, Secret }++++@Composable++fun toneColor(tone: Tone): Color = when (tone) {++ Tone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant++ Tone.Info -> MaterialTheme.colorScheme.tertiary++ Tone.Ok -> Color(0xFF22C55E)++ Tone.Warn -> Color(0xFFF59E0B)++ Tone.Bad -> MaterialTheme.colorScheme.error++ Tone.Secret -> Color(0xFFA78BFA)++}++++/** A screen: its title and one line, then the content, as a list that scrolls under the bar. */++@Composable++fun LinkPage(title: String, lead: String?, modifier: Modifier = Modifier, content: LazyListScope.() -> Unit) {++ LazyColumn(++ modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),++ contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp),++ verticalArrangement = Arrangement.spacedBy(12.dp),++ ) {++ item {++ Column(Modifier.padding(bottom = 6.dp)) {++ Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)++ if (!lead.isNullOrBlank()) {++ Text(lead, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp))++ }++ }++ }++ content()++ }++}++++/** A card on the raised surface, with a hairline and the house's 18 dp corners. */++@Composable++fun LinkCard(modifier: Modifier = Modifier, accent: Color? = null, content: @Composable ColumnScope.() -> Unit) {++ Surface(++ modifier = modifier.fillMaxWidth(),++ shape = RoundedCornerShape(18.dp),++ color = MaterialTheme.colorScheme.surface,++ border = BorderStroke(1.dp, accent?.copy(alpha = 0.45f) ?: MaterialTheme.colorScheme.outlineVariant),++ ) {++ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content)++ }++}++++/** A small pill: an icon and a word, tinted by its tone. */++@Composable++fun ToneChip(text: String, tone: Tone, icon: ImageVector? = null) {++ val c = toneColor(tone)++ Row(++ Modifier.background(c.copy(alpha = 0.14f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 4.dp),++ verticalAlignment = Alignment.CenterVertically,++ horizontalArrangement = Arrangement.spacedBy(6.dp),++ ) {++ if (icon != null) Icon(icon, contentDescription = null, tint = c, modifier = Modifier.size(14.dp))++ Text(text, color = c, fontSize = 12.sp, fontWeight = FontWeight.Bold)++ }++}++++/** A dot that says the state of a thing (green running, amber waiting, grey later). Still: it never pulses. */++@Composable++fun StatusDot(tone: Tone, modifier: Modifier = Modifier) {++ Box(modifier.size(10.dp).background(toneColor(tone), CircleShape))++}++++/** The first answer is on its way: still blocks where cards will be. */++fun LazyListScope.skeletonCards(count: Int = 3) {++ items(count) {++ LinkCard {++ Box(Modifier.fillMaxWidth(0.7f).height(16.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)))++ Box(Modifier.fillMaxWidth(0.9f).height(12.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)))++ Spacer(Modifier.height(4.dp))++ Box(Modifier.width(96.dp).height(12.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)))++ }++ }++}++++/** Nothing here yet: an icon, a sentence that says what will appear, and why it is empty. */++@Composable++fun EmptyState(icon: ImageVector, title: String, body: String) {++ Column(++ Modifier.fillMaxWidth().padding(vertical = 36.dp, horizontal = 12.dp),++ horizontalAlignment = Alignment.CenterHorizontally,++ verticalArrangement = Arrangement.spacedBy(10.dp),++ ) {++ Box(++ Modifier.size(64.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), CircleShape),++ contentAlignment = Alignment.Center,++ ) { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(30.dp)) }++ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)++ Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = androidx.compose.ui.text.style.TextAlign.Center)++ }++}++++/** A label and its value on one line, the label muted. */++@Composable++fun LabelledRow(label: String, value: String) {++ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {++ Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.38f))++ Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.62f))++ }++}nnew file mode 100644@@@ -0,0 +1,192 @@++package network.openvibe.link.ui++++import androidx.compose.material3.FilledTonalButton++import androidx.compose.foundation.background++import androidx.compose.foundation.layout.Arrangement++import androidx.compose.foundation.layout.Box++import androidx.compose.foundation.layout.Row++import androidx.compose.foundation.layout.heightIn++import androidx.compose.foundation.layout.size++import androidx.compose.foundation.shape.CircleShape++import androidx.compose.material.icons.Icons++import androidx.compose.material.icons.outlined.QrCodeScanner++import androidx.compose.material3.Icon++import androidx.compose.ui.Alignment++import androidx.compose.ui.text.font.FontWeight++import android.Manifest++import android.content.pm.PackageManager++import android.os.Build++import androidx.activity.compose.rememberLauncherForActivityResult++import androidx.activity.result.contract.ActivityResultContracts++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.rememberScrollState++import androidx.compose.foundation.verticalScroll++import androidx.compose.material3.Button++import androidx.compose.material3.CircularProgressIndicator++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.OutlinedButton++import androidx.compose.material3.OutlinedTextField++import androidx.compose.material3.Text++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.rememberCoroutineScope++import androidx.compose.runtime.saveable.rememberSaveable++import androidx.compose.runtime.setValue++import androidx.compose.ui.Modifier++import androidx.compose.ui.platform.LocalContext++import androidx.compose.ui.unit.dp++import androidx.core.content.ContextCompat++import com.journeyapps.barcodescanner.ScanContract++import com.journeyapps.barcodescanner.ScanOptions++import kotlinx.coroutines.launch++import network.openvibe.link.data.Api++import network.openvibe.link.data.PairLinks++import network.openvibe.link.data.Store++++/**++ * The first screen: the code from openvibe.bot/link/phone, typed or scanned. Pairing is the Node's own: the code is++ * redeemed on OpenVibe.Network and the phone is bound on OpenVibe.Bot as a device of kind "phone".++ */++@Composable++fun PairScreen(initialCode: String?) {++ val context = LocalContext.current++ val scope = rememberCoroutineScope()++ var code by rememberSaveable { mutableStateOf(initialCode.orEmpty()) }++ var pairing by rememberSaveable { mutableStateOf<String?>(null) }++ var busy by remember { mutableStateOf(false) }++ var error by remember { mutableStateOf<String?>(null) }++++ LaunchedEffect(initialCode) {++ if (!initialCode.isNullOrBlank()) code = initialCode++ }++ // A link that named another server was refused before it got here (MainActivity, PairLinks): say which, plainly.++ LaunchedEffect(Incoming.pairRefused) {++ val refused = Incoming.pairRefused ?: return@LaunchedEffect++ error = "That link points at $refused, not OpenVibe Network. Only a code from openvibe.bot/link/phone pairs this phone."++ Incoming.pairRefused = null++ }++++ val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->++ val link = PairLinks.codeFromScan(result.contents)++ when {++ link?.refusedServer != null -> {++ error = "That QR points at ${link.refusedServer}, not OpenVibe Network."++ Messages.show("That QR points at ${link.refusedServer}, not OpenVibe Network.")++ }++ link?.code != null -> {++ code = link.code++ pairing = link.pairing++ }++ result.contents != null -> Messages.show("That QR is not an OpenVibe pairing link.")++ }++ }++ val askCamera = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->++ if (granted) launchScanner(scanner) else Messages.show("The camera is how the QR is read; you can type the code instead.")++ }++++ Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp)) {++ Text("Link this phone", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)++ Text(++ "Your agents' approvals in your pocket, and Send to OpenVibe from any app.",++ style = MaterialTheme.typography.bodyLarge,++ color = MaterialTheme.colorScheme.onSurfaceVariant,++ modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),++ )++ LinkCard {++ PairStep(1, "On your computer, open openvibe.bot/link/phone, signed in as you.")++ PairStep(2, "Scan the QR it shows, or type its code below.")++ }++ Button(++ onClick = {++ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {++ launchScanner(scanner)++ } else {++ askCamera.launch(Manifest.permission.CAMERA)++ }++ },++ modifier = Modifier.fillMaxWidth().padding(top = 16.dp).heightIn(min = 52.dp),++ ) {++ Icon(Icons.Outlined.QrCodeScanner, contentDescription = null, modifier = Modifier.padding(end = 10.dp))++ Text("Scan the QR", fontWeight = FontWeight.Bold)++ }++ Text(++ "Or type the code",++ style = MaterialTheme.typography.labelLarge,++ color = MaterialTheme.colorScheme.onSurfaceVariant,++ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),++ )++++ OutlinedTextField(++ value = code,++ onValueChange = { code = it.uppercase() },++ label = { Text("Pairing code") },++ placeholder = { Text("ABCD-1234") },++ singleLine = true,++ modifier = Modifier.fillMaxWidth(),++ )++++ error?.let {++ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 12.dp))++ }++++ FilledTonalButton(++ onClick = {++ if (busy) return@FilledTonalButton++ busy = true++ error = null++ scope.launch {++ try {++ val name = "Link · ${Build.MODEL ?: "phone"}"++ val creds = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {++ mobile.Mobile.pair(Api.NETWORK, Api.BOT, code, name, pairing ?: "")++ }++ Store.savePairing(creds, name)++ Messages.show("This phone is linked.")++ } catch (e: Exception) {++ error = e.message ?: "Pairing failed."++ } finally {++ busy = false++ }++ }++ },++ enabled = !busy && code.isNotBlank(),++ modifier = Modifier.fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp),++ ) {++ Text(if (busy) "Pairing…" else "Pair this phone")++ }++++++ Text(++ "A code works once and lasts 15 minutes. Nothing on this phone is backed up: the pairing is encrypted with a key this phone's Keystore holds.",++ style = MaterialTheme.typography.bodySmall,++ modifier = Modifier.padding(top = 24.dp),++ )++ }++}++++private fun launchScanner(scanner: androidx.activity.result.ActivityResultLauncher<ScanOptions>) {++ scanner.launch(++ ScanOptions()++ .setDesiredBarcodeFormats(ScanOptions.QR_CODE)++ .setPrompt("Scan the QR on openvibe.bot/link/phone")++ .setBeepEnabled(false)++ .setOrientationLocked(false),++ )++}++++/** One numbered step of pairing. */++@Composable++private fun PairStep(n: Int, text: String) {++ Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {++ Box(++ Modifier.size(26.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), CircleShape),++ contentAlignment = Alignment.Center,++ ) { Text("$n", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelLarge) }++ Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 3.dp))++ }++}nnew file mode 100644@@@ -0,0 +1,171 @@++package network.openvibe.link.ui++++import androidx.compose.foundation.BorderStroke++import androidx.compose.foundation.background++import androidx.compose.foundation.layout.Box++import androidx.compose.foundation.layout.PaddingValues++import androidx.compose.foundation.layout.size++import androidx.compose.foundation.shape.RoundedCornerShape++import androidx.compose.material.icons.Icons++import androidx.compose.material.icons.outlined.PhoneAndroid++import androidx.compose.material3.ButtonDefaults++import androidx.compose.material3.Icon++import androidx.compose.ui.text.font.FontWeight++import androidx.compose.foundation.layout.Arrangement++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.Row++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.rememberScrollState++import androidx.compose.foundation.verticalScroll++import androidx.compose.material3.AlertDialog++import androidx.compose.material3.Button++import androidx.compose.material3.Card++import androidx.compose.material3.HorizontalDivider++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.OutlinedButton++import androidx.compose.material3.Switch++import androidx.compose.material3.Text++import androidx.compose.material3.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.setValue++import androidx.compose.ui.Alignment++import androidx.compose.ui.Modifier++import androidx.compose.ui.platform.LocalContext++import androidx.compose.ui.unit.dp++import network.openvibe.link.data.LinkController++import network.openvibe.link.data.Session++import network.openvibe.link.data.Store++import network.openvibe.link.service.StayConnectedService++import org.json.JSONObject++++/** "This phone": what it is paired as, Stay connected, sign-in, and Unpair. */++@Composable++fun PhoneScreen(previewInfo: JSONObject? = null, preview: Boolean = false) {++ val context = LocalContext.current++ val info = previewInfo ?: remember(Store.paired) { LinkController.info()?.let { runCatching { JSONObject(it) }.getOrNull() } }++ var confirmUnpair by remember { mutableStateOf(false) }++++ var showIds by remember { mutableStateOf(false) }++ Column(++ Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 24.dp),++ verticalArrangement = Arrangement.spacedBy(14.dp),++ ) {++ Text("This phone", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)++ Text(++ "One of your devices. It has no camera or screen access: in the background it only keeps one connection open.",++ style = MaterialTheme.typography.bodyMedium,++ color = MaterialTheme.colorScheme.onSurfaceVariant,++ )++++ LinkCard {++ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {++ Box(++ Modifier.size(48.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), RoundedCornerShape(14.dp)),++ contentAlignment = Alignment.Center,++ ) { Icon(Icons.Outlined.PhoneAndroid, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }++ Column(Modifier.weight(1f)) {++ Text(Store.deviceLabel.ifBlank { "This phone" }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)++ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {++ StatusDot(if (LinkController.state == "connected") Tone.Ok else if (LinkController.state == "revoked") Tone.Bad else Tone.Neutral)++ Text(statusLine(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)++ }++ }++ }++ TextButton(onClick = { showIds = !showIds }, contentPadding = PaddingValues(0.dp)) {++ Text(if (showIds) "Hide the details" else "Details: device and node ids")++ }++ if (showIds) {++ LabelledRow("Device", info?.optString("device_id")?.takeIf { it.isNotEmpty() } ?: "Binding…")++ LabelledRow("Kind", info?.optString("kind") ?: "phone")++ info?.optString("network")?.takeIf { it.isNotEmpty() }?.let { LabelledRow("Network", it) }++ val node = info?.optString("principal").orEmpty()++ if (node.isNotEmpty()) LabelledRow("Node", node)++ }++ }++++ LinkCard {++ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {++ Column(Modifier.weight(1f)) {++ Text("Stay connected", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)++ Text(++ "Approvals arrive even when the app is closed, with a notification you can always see. Off, the phone connects only while the app is open.",++ style = MaterialTheme.typography.bodySmall,++ color = MaterialTheme.colorScheme.onSurfaceVariant,++ )++ }++ Switch(++ checked = Store.stayConnected,++ onCheckedChange = { on ->++ Store.keepConnected(on)++ if (on) StayConnectedService.start(context) else StayConnectedService.stop(context)++ },++ )++ }++ }++++ if (Store.signedIn) {++ Text("Signed in to your OpenVibe account.", style = MaterialTheme.typography.bodyMedium)++ OutlinedButton(++ onClick = {++ Session.signOut()++ ActionsRepo.clear()++ },++ ) { Text("Sign out") }++ } else {++ Button(++ onClick = {++ if (!Session.signIn(context)) Messages.show("No browser answered. Install one and try again.")++ },++ modifier = Modifier.fillMaxWidth(),++ ) { Text("Sign in to OpenVibe") }++ }++++ OutlinedButton(++ onClick = { confirmUnpair = true },++ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),++ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),++ border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)),++ ) {++ Text("Unpair this phone")++ }++ Text(++ "Unpairing forgets the phone's credential on this device. Remove the device on openvibe.bot as well if you want it gone from your account.",++ style = MaterialTheme.typography.bodySmall,++ modifier = Modifier.padding(top = 8.dp),++ )++ }++++ if (confirmUnpair) {++ AlertDialog(++ onDismissRequest = { confirmUnpair = false },++ title = { Text("Unpair this phone?") },++ text = { Text("It stops receiving approvals and can no longer send files. Nothing else on the phone is deleted.") },++ confirmButton = {++ TextButton(onClick = {++ confirmUnpair = false++ StayConnectedService.stop(context)++ Store.keepConnected(false)++ LinkController.stop()++ Session.signOut()++ ActionsRepo.clear()++ Store.forgetPairing()++ }) { Text("Unpair") }++ },++ dismissButton = {++ TextButton(onClick = { confirmUnpair = false }) { Text("Keep it") }++ },++ )++ }++}++++++private fun statusLine(): String = when (LinkController.state) {++ "connected" -> "Connected to OpenVibe."++ "revoked" -> LinkController.detail ?: "This phone was unpaired. Pair it again."++ "offline" -> "Offline. Reconnecting while Stay connected is on."++ else -> "Not connected."++}nnew file mode 100644@@@ -0,0 +1,70 @@++package network.openvibe.link.ui++++import androidx.compose.runtime.getValue++import androidx.compose.runtime.mutableStateOf++import androidx.compose.runtime.setValue++import network.openvibe.link.data.ActionItem++import network.openvibe.link.data.ActionsDoc++import network.openvibe.link.data.Api++import network.openvibe.link.data.Session++import network.openvibe.link.data.Store++++/** Intents the activity received, for the Compose tree to read (a deep link can arrive while it is already drawn). */++object Incoming {++ var pairCode: String? by mutableStateOf(null)++ /** The server a refused pair link named, so the pair screen can say it instead of pairing with it. */++ var pairRefused: String? by mutableStateOf(null)++ var authUri: String? by mutableStateOf(null)++}++++/** One short line for the person: a toast, then cleared. */++object Messages {++ var text: String? by mutableStateOf(null)++++ fun show(message: String?) {++ if (!message.isNullOrBlank()) text = message++ }++}++++/**++ * The person's Action Center, the same answer the Dock's activity tab and the bell read. It is cached in memory only++ * for the life of the process; every screen that needs it asks for a refresh.++ */++object ActionsRepo {++ var doc: ActionsDoc? by mutableStateOf(null)++ private set++ var loading: Boolean by mutableStateOf(false)++ private set++ var error: String? by mutableStateOf(null)++ private set++++ val approvals: List<ActionItem>++ get() = doc?.buckets?.values?.flatten()?.filter { it.isApproval && it.isPending }?.sortedByDescending { it.createdAt }.orEmpty()++++ val activity: List<ActionItem>++ get() = doc?.buckets?.values?.flatten()?.sortedByDescending { it.updatedAt ?: it.createdAt }.orEmpty()++++ suspend fun refresh() {++ if (!Store.signedIn) return++ if (!Session.hasSession()) return++ loading = true++ error = null++ try {++ doc = Api.actions()++ } catch (e: Exception) {++ error = e.message ?: "Could not read your activity."++ } finally {++ loading = false++ }++ }++++ suspend fun decide(item: ActionItem, approve: Boolean) {++ Api.decide(item, approve)++ refresh()++ }++++ fun clear() {++ doc = null++ error = null++ }++}nnew file mode 100644@@@ -0,0 +1,52 @@++package network.openvibe.link.ui++++import androidx.compose.foundation.isSystemInDarkTheme++import androidx.compose.material3.MaterialTheme++import androidx.compose.runtime.Composable++import network.openvibe.theme.VibeDarkColorScheme++import network.openvibe.theme.VibeDarkTokens++import network.openvibe.theme.VibeLightColorScheme++import network.openvibe.theme.VibeLightTokens++++/**++ * The rest of OpenVibe, on the phone: the generated Vibe theme (openvibe-shared's native export), never hand-drawn.++ *++ * Material 3's container roles (cards, the bottom bar and its selected pill, dialogs, chips) are not in the export.++ * Left alone they fall back to Material's own lavender, which is what the first screens showed. Here they take the++ * Vibe surfaces, so nothing on screen is a colour OpenVibe does not use.++ */++@Composable++fun LinkTheme(dark: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {++ val scheme = if (dark) {++ VibeDarkColorScheme.copy(++ surfaceContainerLowest = VibeDarkTokens.ColorBgBase,++ surfaceContainerLow = VibeDarkTokens.ColorBgRaised,++ surfaceContainer = VibeDarkTokens.ColorBgRaised,++ surfaceContainerHigh = VibeDarkTokens.ColorBgSurface,++ surfaceContainerHighest = VibeDarkTokens.ColorBgSubtle,++ surfaceBright = VibeDarkTokens.ColorBgHover,++ surfaceDim = VibeDarkTokens.ColorBgBase,++ surfaceTint = VibeDarkTokens.ColorBgRaised,++ secondaryContainer = VibeDarkTokens.ColorBgHover,++ onSecondaryContainer = VibeDarkTokens.ColorAccentLight,++ inverseSurface = VibeDarkTokens.ColorTextPrimary,++ inverseOnSurface = VibeDarkTokens.ColorBgBase,++ )++ } else {++ VibeLightColorScheme.copy(++ surfaceContainerLowest = VibeLightTokens.ColorBgRaised,++ surfaceContainerLow = VibeLightTokens.ColorBgRaised,++ surfaceContainer = VibeLightTokens.ColorBgRaised,++ surfaceContainerHigh = VibeLightTokens.ColorBgSurface,++ surfaceContainerHighest = VibeLightTokens.ColorBgSubtle,++ surfaceBright = VibeLightTokens.ColorBgRaised,++ surfaceDim = VibeLightTokens.ColorBgSubtle,++ surfaceTint = VibeLightTokens.ColorBgRaised,++ secondaryContainer = VibeLightTokens.ColorBgHover,++ onSecondaryContainer = VibeLightTokens.ColorAccentStrong,++ inverseSurface = VibeLightTokens.ColorTextPrimary,++ inverseOnSurface = VibeLightTokens.ColorBgRaised,++ )++ }++ MaterialTheme(colorScheme = scheme, content = content)++}nnew file mode 100644@@@ -0,0 +1,147 @@++// Vibe (dark) — generated by openvibe-shared/tokens v1 from the OpenVibe theme "vibe". Regenerate instead of editing.++package network.openvibe.theme++++import androidx.compose.material3.ColorScheme++import androidx.compose.material3.darkColorScheme++import androidx.compose.material3.lightColorScheme++import androidx.compose.ui.graphics.Color++import androidx.compose.ui.unit.dp++import androidx.compose.ui.unit.sp++++/** OpenVibe's default: deep blue night with an electric-blue glow. */++object VibeDarkTokens {++ val ColorBgBase = Color(0xFF0A0F1C)++ val ColorBgSurface = Color(0xFF101828)++ val ColorBgSubtle = Color(0xFF172238)++ val ColorBgRaised = Color(0xFF131C2E)++ val ColorBgHover = Color(0xFF1C2A44)++ val ColorBgInput = Color(0xFF0D1424)++ val ColorTextPrimary = Color(0xFFE6EDF7)++ val ColorTextSecondary = Color(0xFF96A7C2)++ val ColorTextMuted = Color(0xFF8192AC)++ val ColorAccentBase = Color(0xFF3B82F6)++ val ColorAccentLight = Color(0xFF60A5FA)++ val ColorAccentDark = Color(0xFF1D4ED8)++ val ColorAccentStrong = Color(0xFF3472D8)++ val ColorAccentOn = Color(0xFFFFFFFF)++ val ColorAccentOnStrong = Color(0xFFFFFFFF)++ val ColorAccentGlow = Color(0x593B82F6)++ val ColorLineBase = Color(0xFF1F2D47)++ val ColorLineStrong = Color(0xFF2C3D5C)++ val ColorStatusOkBase = Color(0xFF22C55E)++ val ColorStatusOkText = Color(0xFF22C55E)++ val ColorStatusWarnBase = Color(0xFFF59E0B)++ val ColorStatusWarnText = Color(0xFFF59E0B)++ val ColorStatusBadBase = Color(0xFFEF4444)++ val ColorStatusBadText = Color(0xFFF26262)++ val ColorStatusInfoBase = Color(0xFF38BDF8)++ val ColorStatusInfoText = Color(0xFF38BDF8)++ val ColorStatusLiveBase = Color(0xFFEF4444)++ val ColorStatusLiveText = Color(0xFFF26262)++}++++/** Material 3 roles: MaterialTheme(colorScheme = VibeDarkColorScheme) { … } */++val VibeDarkColorScheme: ColorScheme = darkColorScheme(++ primary = VibeDarkTokens.ColorAccentBase,++ onPrimary = VibeDarkTokens.ColorAccentOn,++ primaryContainer = VibeDarkTokens.ColorAccentStrong,++ onPrimaryContainer = VibeDarkTokens.ColorAccentOnStrong,++ secondary = VibeDarkTokens.ColorAccentLight,++ onSecondary = VibeDarkTokens.ColorAccentOn,++ tertiary = VibeDarkTokens.ColorStatusInfoBase,++ background = VibeDarkTokens.ColorBgBase,++ onBackground = VibeDarkTokens.ColorTextPrimary,++ surface = VibeDarkTokens.ColorBgRaised,++ onSurface = VibeDarkTokens.ColorTextPrimary,++ surfaceVariant = VibeDarkTokens.ColorBgSurface,++ onSurfaceVariant = VibeDarkTokens.ColorTextSecondary,++ outline = VibeDarkTokens.ColorLineStrong,++ outlineVariant = VibeDarkTokens.ColorLineBase,++ error = VibeDarkTokens.ColorStatusBadBase,++ onError = VibeDarkTokens.ColorAccentOnStrong,++)++++/** OpenVibe's default in daylight: airy blue-white with the same blue. */++object VibeLightTokens {++ val ColorBgBase = Color(0xFFF0F5FF)++ val ColorBgSurface = Color(0xFFE8EEF8)++ val ColorBgSubtle = Color(0xFFDDE5F0)++ val ColorBgRaised = Color(0xFFF5F8FF)++ val ColorBgHover = Color(0xFFE4ECF8)++ val ColorBgInput = Color(0xFFEDF2FA)++ val ColorTextPrimary = Color(0xFF1A2040)++ val ColorTextSecondary = Color(0xFF4A5070)++ val ColorTextMuted = Color(0xFF5E637B)++ val ColorAccentBase = Color(0xFF245DD5)++ val ColorAccentLight = Color(0xFF3C669B)++ val ColorAccentDark = Color(0xFF2563EB)++ val ColorAccentStrong = Color(0xFF245DD5)++ val ColorAccentOn = Color(0xFFFFFFFF)++ val ColorAccentOnStrong = Color(0xFFFFFFFF)++ val ColorAccentGlow = Color(0x59245DD5)++ val ColorLineBase = Color(0xFFD0D8EA)++ val ColorLineStrong = Color(0xFFBCC8E0)++ val ColorStatusOkBase = Color(0xFF22C55E)++ val ColorStatusOkText = Color(0xFF147237)++ val ColorStatusWarnBase = Color(0xFFF59E0B)++ val ColorStatusWarnText = Color(0xFF895806)++ val ColorStatusBadBase = Color(0xFFEF4444)++ val ColorStatusBadText = Color(0xFFBA3535)++ val ColorStatusInfoBase = Color(0xFF38BDF8)++ val ColorStatusInfoText = Color(0xFF1F6A8B)++ val ColorStatusLiveBase = Color(0xFFEF4444)++ val ColorStatusLiveText = Color(0xFFBA3535)++}++++/** Material 3 roles: MaterialTheme(colorScheme = VibeLightColorScheme) { … } */++val VibeLightColorScheme: ColorScheme = lightColorScheme(++ primary = VibeLightTokens.ColorAccentBase,++ onPrimary = VibeLightTokens.ColorAccentOn,++ primaryContainer = VibeLightTokens.ColorAccentStrong,++ onPrimaryContainer = VibeLightTokens.ColorAccentOnStrong,++ secondary = VibeLightTokens.ColorAccentLight,++ onSecondary = VibeLightTokens.ColorAccentOn,++ tertiary = VibeLightTokens.ColorStatusInfoBase,++ background = VibeLightTokens.ColorBgBase,++ onBackground = VibeLightTokens.ColorTextPrimary,++ surface = VibeLightTokens.ColorBgRaised,++ onSurface = VibeLightTokens.ColorTextPrimary,++ surfaceVariant = VibeLightTokens.ColorBgSurface,++ onSurfaceVariant = VibeLightTokens.ColorTextSecondary,++ outline = VibeLightTokens.ColorLineStrong,++ outlineVariant = VibeLightTokens.ColorLineBase,++ error = VibeLightTokens.ColorStatusBadBase,++ onError = VibeLightTokens.ColorAccentOnStrong,++)++++/** The OpenVibe kit's radius, space, size, type and motion scales: the same in every theme. */++object OvScale {++ val RadiusSm = 10.dp++ val RadiusMd = 14.dp++ val RadiusLg = 20.dp++ val RadiusPill = 999.dp++ val SpaceXs = 4.dp++ val SpaceSm = 8.dp++ val SpaceMd = 12.dp++ val SpaceLg = 16.dp++ val SpaceXl = 24.dp++ val SpaceXxl = 32.dp++ val SizeTap = 44.dp++ val SizeIcon = 20.dp++ val SizeAvatar = 34.dp++ val TypeSizeCaption = 12.sp++ val TypeSizeMeta = 13.sp++ val TypeSizeBody = 15.sp++ val TypeSizeLead = 17.sp++ val TypeSizeTitle = 20.sp++ val TypeSizeHeadline = 24.sp++ val TypeSizeDisplay = 34.sp++ const val TypeWeightRegular = 400++ const val TypeWeightMedium = 600++ const val TypeWeightBold = 700++ const val TypeWeightHeavy = 800++ const val MotionDurationFast = 180++ const val MotionDurationBase = 260++ const val MotionDurationSlow = 360++ const val MotionDurationReduced = 0++}nnew file mode 100644@@@ -0,0 +1,16 @@++<?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">++ <path++ android:fillColor="#0A0F1C"++ android:pathData="M0,0h108v108h-108z" />++ <path++ android:fillColor="#3B82F6"++ android:pathData="M34,44h40a6,6 0 0 1 6,6v20a6,6 0 0 1 -6,6h-40a6,6 0 0 1 -6,-6v-20a6,6 0 0 1 6,-6z" />++ <path++ android:fillColor="#E6EDF7"++ android:pathData="M48,52h12v12h-12z" />++</vector>nnew file mode 100644@@@ -0,0 +1,10 @@++<?xml version="1.0" encoding="utf-8"?>++<vector xmlns:android="http://schemas.android.com/apk/res/android"++ android:width="24dp"++ android:height="24dp"++ android:viewportWidth="24"++ android:viewportHeight="24">++ <path++ android:fillColor="#FFFFFFFF"++ android:pathData="M12,2a2,2 0 0 1 2,2v11.6l2.3,-2.3a1,1 0 0 1 1.4,1.4l-4,4a2,2 0 0 1 -2.8,0l-4,-4a1,1 0 1 1 1.4,-1.4l2.3,2.3v-11.6a2,2 0 0 1 2,-2z" />++</vector>nnew file mode 100644@@@ -0,0 +1,6 @@++<?xml version="1.0" encoding="utf-8"?>++<resources>++ <!-- Vibe's own tokens (openvibe-shared): deep blue night, electric-blue glow. -->++ <color name="ov_bg_base">#FF0A0F1C</color>++ <color name="ov_accent">#FF3B82F6</color>++</resources>nnew file mode 100644@@@ -0,0 +1,7 @@++<?xml version="1.0" encoding="utf-8"?>++<resources>++ <string name="app_name">OpenVibe Link</string>++ <string name="share_label">Send to OpenVibe</string>++ <string name="channel_stay_connected">Stay connected</string>++ <string name="channel_downloads">Send to OpenVibe</string>++</resources>nnew file mode 100644@@@ -0,0 +1,15 @@++<?xml version="1.0" encoding="utf-8"?>++<resources>++ <style name="Theme.OpenVibe" parent="Theme.AppCompat.DayNight.NoActionBar">++ <item name="android:windowBackground">@color/ov_bg_base</item>++ <item name="android:statusBarColor">@color/ov_bg_base</item>++ <item name="android:navigationBarColor">@color/ov_bg_base</item>++ <item name="android:windowLightStatusBar">false</item>++ </style>++++ <style name="Theme.OpenVibe.Translucent" parent="Theme.AppCompat.DayNight.NoActionBar">++ <item name="android:windowIsTranslucent">true</item>++ <item name="android:windowBackground">@android:color/transparent</item>++ <item name="android:windowNoTitle">true</item>++ </style>++</resources>nnew file mode 100644@@@ -0,0 +1,15 @@++<?xml version="1.0" encoding="utf-8"?>++<!-- Nothing of this app is backed up: the pairing and the session live in the Android Keystore, and a backup would++ carry ciphertext a restored phone cannot read. -->++<data-extraction-rules>++ <cloud-backup>++ <exclude domain="root" path="." />++ <exclude domain="sharedpref" path="." />++ <exclude domain="file" path="." />++ </cloud-backup>++ <device-transfer>++ <exclude domain="root" path="." />++ <exclude domain="sharedpref" path="." />++ <exclude domain="file" path="." />++ </device-transfer>++</data-extraction-rules>nnew file mode 100644@@@ -0,0 +1,70 @@++package network.openvibe.link++++import network.openvibe.link.approval.ApprovalCategory++import network.openvibe.link.approval.ApprovalRules++import org.junit.Assert.assertEquals++import org.junit.Assert.assertFalse++import org.junit.Assert.assertTrue++import org.junit.Test++++class ApprovalRulesTest {++ @Test++ fun `money needs a second factor`() {++ assertEquals(ApprovalCategory.MONEY, ApprovalRules.classify(null, "codes.task", "Buy a laptop for $1,200", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Pay the hosting invoice", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Tip the streamer 5 EUR", null))++ }++++ @Test++ fun `passwords and one-time codes need a second factor`() {++ assertEquals(ApprovalCategory.PASSWORD, ApprovalRules.classify(null, "codes.task", "Use the password vault", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Fill in the login form", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Read the 2FA code from the authenticator", null))++ }++++ @Test++ fun `deleting needs a second factor`() {++ assertEquals(ApprovalCategory.DELETE, ApprovalRules.classify(null, "codes.task", "Delete the old repository", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Wipe the drive", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Cancel my subscription", null))++ }++++ @Test++ fun `a send to someone new needs a second factor`() {++ assertEquals(ApprovalCategory.NEW_RECIPIENT, ApprovalRules.classify(null, "codes.task", "Send this to a new contact", null))++ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Email the report to Dana", null))++ }++++ @Test++ fun `an ordinary task is answered without a prompt`() {++ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify(null, "codes.task", "Write a summary of the notes", null))++ assertFalse(ApprovalRules.needsBiometrics(null, "codes.task", "Write a summary of the notes", null))++ assertFalse(ApprovalRules.needsBiometrics(null, "codes.task", "Display the list", null))++ }++++ @Test++ fun `the service's own category wins`() {++ assertEquals(ApprovalCategory.MONEY, ApprovalRules.classify("money", "codes.task", "Rename a file", null))++ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify("other", "codes.task", "Delete everything", null))++ assertFalse(ApprovalRules.needsBiometrics("other", "codes.task", "Pay the invoice", null))++ }++++ @Test++ fun `an unknown category falls back to the words`() {++ assertEquals(ApprovalCategory.DELETE, ApprovalRules.classify("something_new", "codes.task", "Delete it", null))++ }++++ @Test++ fun `the summary counts when the title is plain`() {++ assertEquals(++ ApprovalCategory.MONEY,++ ApprovalRules.classify(null, "approval", "A coding task", "It would spend 40 USD on the API"),++ )++ }++++ @Test++ fun `nothing at all is other`() {++ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify(null, null, null, null))++ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify(" ", "", "", ""))++ }++}nnew file mode 100644@@@ -0,0 +1,93 @@++package network.openvibe.link++++import network.openvibe.link.data.PairLinks++import network.openvibe.link.data.Pkce++import network.openvibe.link.data.Servers++import org.junit.Assert.assertEquals++import org.junit.Assert.assertFalse++import org.junit.Assert.assertNotEquals++import org.junit.Assert.assertNull++import org.junit.Assert.assertTrue++import org.junit.Test++++class PairLinksTest {++ private val ours = "https://openvibe.network"++++ @Test++ fun `the QR link carries the code and the pairing id`() {++ val link = PairLinks.parse("openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR", ours)!!++ assertEquals("ABCD-1234", link.code)++ assertEquals("pair_01JD2C3D4E5F6G7H8J9K0MNPQR", link.pairing)++ assertNull(link.refusedServer)++ assertEquals(ours, link.network)++ assertNull(PairLinks.parse("openvibe-link://pair?code=ABCD-1234&pairing=nonsense", ours)!!.pairing)++ }++++ @Test++ fun `a link with no server is ours`() {++ assertNull(PairLinks.parse("openvibe-link://pair?code=ABCD-1234", ours)!!.refusedServer)++ assertEquals(ours, PairLinks.parse("openvibe-link://pair?code=ABCD-1234", ours)!!.network)++ }++++ @Test++ fun `a link that names another server is refused whole, with no code`() {++ val link = PairLinks.parse("openvibe-link://pair?code=ABCD-1234&network=https%3A%2F%2Fevil.test", ours)!!++ assertEquals("evil.test", link.refusedServer)++ assertNull("a code from another server is never redeemed", link.code)++ assertNull(link.pairing)++ // A plain http URL is not a server this app can talk to either.++ assertEquals("http://evil.test", PairLinks.parse("openvibe-link://pair?code=ABCD-1234&network=http%3A%2F%2Fevil.test", ours)!!.refusedServer)++ // Our own server, written differently, is still ours.++ assertNull(PairLinks.parse("openvibe-link://pair?code=ABCD-1234&network=https%3A%2F%2FOPENVIBE.network%2F", ours)!!.refusedServer)++ }++++ @Test++ fun `another scheme is not a pairing link`() {++ assertNull(PairLinks.parse("https://openvibe.bot/link/phone?code=ABCD-1234", ours))++ assertNull(PairLinks.parse("openvibe-link://auth?code=ABCD-1234", ours))++ assertNull(PairLinks.parse(null, ours))++ }++++ @Test++ fun `a scan of a bare code or a page link is read`() {++ // The code is passed to the Go core as it was scanned; it normalizes the Crockford form.++ assertEquals("abcd 1234", PairLinks.codeFromScan("abcd 1234", ours)!!.code)++ assertEquals("ABCD-1234", PairLinks.codeFromScan("https://openvibe.bot/link/phone?code=ABCD-1234", ours)!!.code)++ assertEquals("evil.test", PairLinks.codeFromScan("https://openvibe.bot/link/phone?code=ABCD-1234&network=https%3A%2F%2Fevil.test", ours)!!.refusedServer)++ assertNull(PairLinks.codeFromScan("https://example.test/hello", ours))++ }++}++++class ServersTest {++ @Test++ fun `a server compares by scheme, host and path, not spelling`() {++ assertTrue(Servers.same("https://openvibe.network", "https://openvibe.network/"))++ assertTrue(Servers.same("https://OpenVibe.Network", "https://openvibe.network"))++ assertFalse(Servers.same("https://evil.test", "https://openvibe.network"))++ assertFalse("https only", Servers.same("http://openvibe.network", "https://openvibe.network"))++ assertNull(Servers.normalize("openvibe.network"))++ }++}++++class PkceTest {++ @Test++ fun `the verifier and the challenge are the RFC 7636 pair`() {++ val verifier = Pkce.verifier()++ assertTrue(Pkce.isVerifier(verifier))++ assertEquals(43, verifier.length)++ // RFC 7636 appendix B: SHA-256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") in base64url.++ assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))++ }++++ @Test++ fun `two requests get different verifiers and states`() {++ assertNotEquals(Pkce.verifier(), Pkce.verifier())++ assertNotEquals(Pkce.state(), Pkce.state())++ }++++ @Test++ fun `a short verifier is refused`() {++ assertFalse(Pkce.isVerifier("short"))++ assertFalse(Pkce.isVerifier(null))++ }++}nnew file mode 100644@@@ -0,0 +1,57 @@++package network.openvibe.link++++import androidx.activity.compose.LocalActivityResultRegistryOwner++import androidx.activity.result.ActivityResultRegistry++import androidx.activity.result.ActivityResultRegistryOwner++import androidx.activity.result.contract.ActivityResultContract++import androidx.compose.foundation.layout.fillMaxSize++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.Surface++import androidx.compose.runtime.Composable++import androidx.compose.runtime.CompositionLocalProvider++import androidx.compose.ui.Modifier++import androidx.core.app.ActivityOptionsCompat++import app.cash.paparazzi.DeviceConfig++import app.cash.paparazzi.Paparazzi++import network.openvibe.link.ui.ActivityScreen++import network.openvibe.link.ui.ApprovalsScreen++import network.openvibe.link.ui.LinkTheme++import network.openvibe.link.ui.PairScreen++import network.openvibe.link.ui.PhoneScreen++import org.junit.Rule++import org.junit.Test++++/**++ * Every screen, drawn from the debug PreviewActivity's sample data, in dark and in light (Pixel 5). The pictures land++ * in src/test/snapshots: `gradle :app:recordPaparazziDebug` writes them, `verifyPaparazziDebug` compares. docs/android.md++ * shows the dark ones. A designer changes a screen, records, and looks.++ */++class ScreensSnapshotTest {++ @get:Rule++ val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5, theme = "android:Theme.Material.NoActionBar")++++ /** The pair screen asks for a scanner result; under Paparazzi there is no activity, so a registry that never launches. */++ private val noResults = object : ActivityResultRegistryOwner {++ override val activityResultRegistry = object : ActivityResultRegistry() {++ override fun <I, O> onLaunch(requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat?) = Unit++ }++ }++++ private fun shot(dark: Boolean, content: @Composable () -> Unit) = paparazzi.snapshot {++ CompositionLocalProvider(LocalActivityResultRegistryOwner provides noResults) {++ LinkTheme(dark = dark) {++ Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { content() }++ }++ }++ }++++ @Test fun approvalsDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_APPROVALS) }++ @Test fun approvalsLight() = shot(false) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_APPROVALS) }++ @Test fun approvalsEmptyDark() = shot(true) { ApprovalsScreen(preview = emptyList()) }++ @Test fun activityDark() = shot(true) { ActivityScreen(preview = PreviewActivity.SAMPLE_ACTIVITY) }++ @Test fun activityLight() = shot(false) { ActivityScreen(preview = PreviewActivity.SAMPLE_ACTIVITY) }++ @Test fun phoneDark() = shot(true) { PhoneScreen(previewInfo = PreviewActivity.SAMPLE_DEVICE, preview = true) }++ @Test fun phoneLight() = shot(false) { PhoneScreen(previewInfo = PreviewActivity.SAMPLE_DEVICE, preview = true) }++ @Test fun pairDark() = shot(true) { PairScreen(initialCode = "ABCD-1234") }++ @Test fun pairLight() = shot(false) { PairScreen(initialCode = "ABCD-1234") }++}nnew file mode 100644@@@ -0,0 +1,47 @@++package network.openvibe.link++++import org.junit.Assert.assertFalse++import org.junit.Assert.assertTrue++import org.junit.Test++import java.io.File++++/**++ * The rules about secrets that a unit test can hold the sources to: the app keeps every token and the pairing behind++ * one Keystore key (Store), and no part of it writes one to a log, a stack trace or an unencrypted preference — a++ * crash report carries logs, so a token in a log is a token on someone else's server. The upload and the prompt are++ * checked in ShareIntentsTest/ApprovalRulesTest; a device is needed for the flows themselves.++ */++class SecretsTest {++ private val main = File("src/main/java/network/openvibe/link")++++ @Test++ fun `nothing in the app logs`() {++ val offenders = main.walkTopDown()++ .filter { it.isFile && it.extension == "kt" }++ .filter { file ->++ val text = file.readText()++ Regex("""\bLog\.[dviwe]\(""").containsMatchIn(text) || text.contains("println(")++ }++ .map { it.name }++ .toList()++ assertTrue("these files log: $offenders", offenders.isEmpty())++ }++++ @Test++ fun `secrets are written only through the Keystore cipher`() {++ val store = File(main, "data/Store.kt").readText()++ assertTrue("the key is the Android Keystore's", store.contains("AndroidKeyStore"))++ assertTrue("AES-GCM seals every value", store.contains("AES/GCM/NoPadding"))++ assertTrue("a stored secret is named through encryptedKey", store.contains("putString(encryptedKey("))++ // No preference write of a plain name: an unencrypted token would sit in the app's data directory.++ assertFalse("an unencrypted value is never written", Regex("""putString\("(access|refresh|pairing)"""").containsMatchIn(store))++ assertTrue("nothing is written when the cipher fails", store.contains("catch (e: Exception)") || store.contains("try {"))++ }++++ @Test++ fun `the session is kept in the app's own storage and excluded from backups`() {++ val manifest = File("src/main/AndroidManifest.xml").readText()++ assertTrue(manifest.contains("android:allowBackup=\"false\""))++ assertTrue(manifest.contains("android:fullBackupContent=\"false\""))++ }++}nnew file mode 100644@@@ -0,0 +1,77 @@++package network.openvibe.link++++import network.openvibe.link.share.ShareIntents++import org.junit.Assert.assertEquals++import org.junit.Assert.assertFalse++import org.junit.Assert.assertNull++import org.junit.Assert.assertTrue++import org.junit.Test++++class ShareIntentsTest {++ @Test++ fun `one file`() {++ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "image/jpeg", listOf("content://media/1"), null)!!++ assertEquals("image/jpeg", share.mimeType)++ assertEquals(listOf("content://media/1"), share.uris)++ assertFalse(share.isMultiple)++ assertTrue(share.hasFiles)++ assertNull(share.text)++ }++++ @Test++ fun `several files keep their order and drop repeats`() {++ val share = ShareIntents.parse(++ ShareIntents.ACTION_SEND_MULTIPLE,++ "image/*",++ listOf("content://media/1", "content://media/2", "content://media/1"),++ null,++ )!!++ assertEquals(listOf("content://media/1", "content://media/2"), share.uris)++ assertTrue(share.isMultiple)++ }++++ @Test++ fun `a single send takes the first stream only`() {++ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "application/pdf", listOf("content://a", "content://b"), null)!!++ assertEquals(listOf("content://a"), share.uris)++ assertFalse(share.isMultiple)++ }++++ @Test++ fun `text on its own is a text share`() {++ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", emptyList(), " a note\nsecond line ")!!++ assertEquals("text/plain", share.mimeType)++ assertFalse(share.hasFiles)++ assertEquals("a note\nsecond line", share.text)++ }++++ @Test++ fun `a wildcard from the sender becomes a sane type`() {++ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "*/*", listOf("content://a"), null)!!++ assertEquals(ShareIntents.FALLBACK_MIME, share.mimeType)++ val text = ShareIntents.parse(ShareIntents.ACTION_SEND, "*/*", emptyList(), "hello")!!++ assertEquals(ShareIntents.TEXT_MIME, text.mimeType)++ }++++ @Test++ fun `nothing to send is null`() {++ assertNull(ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", emptyList(), null))++ assertNull(ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", emptyList(), " "))++ assertNull(ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", listOf(" "), ""))++ }++++ @Test++ fun `another action is not a share`() {++ assertNull(ShareIntents.parse("android.intent.action.VIEW", "text/plain", listOf("content://a"), null))++ assertNull(ShareIntents.parse(null, null, listOf("content://a"), null))++ }++++ @Test++ fun `a pasted text gets a file name from its first line`() {++ assertEquals("Groceries.txt", ShareIntents.nameFor("Groceries\nmilk"))++ assertNull(ShareIntents.nameFor(" \n"))++ assertNull(ShareIntents.nameFor(null))++ val long = "x".repeat(200)++ assertTrue(ShareIntents.nameFor(long)!!.endsWith("….txt"))++ }++}nnew file mode 100644@@@ -0,0 +1,43 @@++package network.openvibe.link++++import network.openvibe.link.share.ShareIntents++import org.junit.Assert.assertEquals++import org.junit.Assert.assertNotNull++import org.junit.Assert.assertNull++import org.junit.Assert.assertTrue++import org.junit.Test++++/**++ * The share sheet asks before it sends: nothing is uploaded on the intent alone, so the question says what will go and++ * Cancel sends nothing (ShareActivity's ConfirmShare). The reading rules themselves are in ShareIntentsTest.++ */++class ShareQuestionTest {++ private fun request(action: String, mime: String?, uris: List<String>, text: String? = null) =++ ShareIntents.parse(action, mime, uris, text)++++ @Test++ fun `one file asks about one file`() {++ val r = request(ShareIntents.ACTION_SEND, "image/png", listOf("content://a/1"))!!++ assertEquals("Send this to your Download?", r.question)++ }++++ @Test++ fun `several files say how many`() {++ val r = request(ShareIntents.ACTION_SEND_MULTIPLE, "image/*", listOf("content://a/1", "content://a/2", "content://a/3"))!!++ assertEquals("Send these 3 files to your Download?", r.question)++ }++++ @Test++ fun `pasted text asks about text`() {++ val r = request(ShareIntents.ACTION_SEND, "text/plain", emptyList(), "hello")!!++ assertEquals("Send this text to your Download?", r.question)++ }++++ @Test++ fun `an empty share has no question because there is nothing to ask`() {++ assertNull(request(ShareIntents.ACTION_SEND, "text/plain", emptyList(), " "))++ assertNull(request("android.intent.action.VIEW", "text/plain", listOf("content://a/1")))++ assertNotNull(request(ShareIntents.ACTION_SEND, "*/*", listOf("content://a/1")))++ assertTrue(request(ShareIntents.ACTION_SEND, "image/png", listOf("content://a/1"))!!.hasFiles)++ }++}nnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsEmptyDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_phoneDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_phoneLight.png differnnew file mode 100644@@@ -0,0 +1,8 @@++// Versions are pinned here and in android/gradle/wrapper/gradle-wrapper.properties; the CI uses the same pair.++plugins {++ id("com.android.application") version "8.7.3" apply false++ id("org.jetbrains.kotlin.android") version "2.0.21" apply false++ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false++ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false++ id("app.cash.paparazzi") version "1.3.5" apply false++}nnew file mode 100644@@@ -0,0 +1 @@++v4.135.0nnew file mode 100644@@@ -0,0 +1,5 @@++org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8++org.gradle.parallel=true++android.useAndroidX=true++android.nonTransitiveRClass=true++kotlin.code.style=officialnnew file mode 100644@@@ -0,0 +1,7 @@++distributionBase=GRADLE_USER_HOME++distributionPath=wrapper/dists++distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip++networkTimeout=10000++validateDistributionUrl=true++zipStoreBase=GRADLE_USER_HOME++zipStorePath=wrapper/distsnnew file mode 100755@@@ -0,0 +1,55 @@++#!/bin/sh++# Bind the Node's Go core (mobile/mobile.go, the pairing and the link) into an Android library the app links against.++# It is the same code the desktop Node runs, so pairing, token refresh and reconnects behave identically.++#++# scripts/build-core.sh # write android/app/libs/core.aar++#++# Needs Go, the Android SDK and NDK (ANDROID_HOME or ANDROID_NDK_HOME), and writes nothing into the repository that is++# committed: android/app/libs/ is ignored.++#++# The bind runs against a temporary module that replaces the Node with this checkout, so the Node's own go.mod (and the++# desktop binaries built from it) never gains the bind tool or its newer dependencies.++set -eu++++unset CDPATH++android=$(cd -- "$(dirname -- "$0")/.." && pwd)++repo=$(cd -- "$android/.." && pwd)++++# Pinned: the bind tool that produced the AAR, so a rebuild is the same build.++GOMOBILE=v0.0.0-20261011034528-197770444867++++if [ -z "${ANDROID_NDK_HOME:-}" ]; then++ home=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}++ [ -n "$home" ] || { echo "set ANDROID_HOME (or ANDROID_NDK_HOME) to the Android SDK" >&2; exit 1; }++ for candidate in "$home"/ndk/*; do [ -d "$candidate" ] && ndk=$candidate; done++ [ -n "$ndk" ] || { echo "no NDK under $home/ndk: install one with sdkmanager 'ndk;<version>'" >&2; exit 1; }++ ANDROID_NDK_HOME=$ndk++ export ANDROID_NDK_HOME++fi++++echo "gomobile $GOMOBILE, NDK $ANDROID_NDK_HOME"++GOBIN=$(go env GOPATH)/bin++GOBIN=$GOBIN go install "golang.org/x/mobile/cmd/gomobile@$GOMOBILE"++GOBIN=$GOBIN go install "golang.org/x/mobile/cmd/gobind@$GOMOBILE"++"$GOBIN/gomobile" init++++tmp=$(mktemp -d)++trap 'rm -rf "$tmp"' EXIT++# A module of its own: the Node's go.mod, with a name of its own (a module cannot require itself) and this checkout++# in place of the published module.++sed 's/^module .*/module openvibe-link\/core/' "$repo/go.mod" > "$tmp/go.mod"++cp "$repo/go.sum" "$tmp/"++printf '\nreplace github.com/OpenVibers/OpenVibe.Node => %s\n' "$repo" >> "$tmp/go.mod"++++(++ cd "$tmp"++ export GOFLAGS="-mod=mod -buildvcs=false"++ # go get, not go mod tidy: tidy would drop a module the bind uses but nothing imports.++ go get github.com/OpenVibers/[email protected] >/dev/null++ go get -tool "golang.org/x/mobile/cmd/gobind@$GOMOBILE" >/dev/null++ mkdir -p "$android/app/libs"++ "$GOBIN/gomobile" bind -target=android/arm64,android/arm -androidapi 26 \++ -o "$android/app/libs/core.aar" github.com/OpenVibers/OpenVibe.Node/mobile++)++++echo "wrote android/app/libs/core.aar"nnew file mode 100755@@@ -0,0 +1,44 @@++#!/bin/sh++# Generate the phone's design system from the pinned openvibe-shared release, so the app wears the same theme and kit++# as every OpenVibe site. Run it after a Design release; never edit the generated files.++#++# scripts/gen-design.sh # rewrite app/src/main/java/network/openvibe/{theme,kit}++# scripts/gen-design.sh --check # refuse if the generated files differ from what is committed++#++# It downloads the release tarball (no npm registry, no token), runs the release's own scripts/native-check.js, and++# copies exactly the Kotlin files it writes. The pinned version is android/design-version.txt.++set -eu++++unset CDPATH++android=$(cd -- "$(dirname -- "$0")/.." && pwd)++version=$(tr -d ' \n' < "$android/design-version.txt")++target="$android/app/src/main/java/network/openvibe"++check=no++[ "${1:-}" = "--check" ] && check=yes++++tmp=$(mktemp -d)++trap 'rm -rf "$tmp"' EXIT++++echo "openvibe-shared $version"++curl -fsSL "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/$version" | tar -xz -C "$tmp"++src=$(find "$tmp" -maxdepth 1 -type d -name 'OpenVibe.Shared-*' | head -n 1)++[ -n "$src" ] || { echo "the release tarball had no OpenVibe.Shared-* directory" >&2; exit 1; }++++node "$src/scripts/native-check.js" --out "$tmp/native"++++mkdir -p "$target/theme" "$target/kit"++cp "$tmp/native/kotlin/VibeTheme.kt" "$target/theme/VibeTheme.kt"++cp "$tmp/native/kotlin/OvKit.kt" "$target/kit/OvKit.kt"++cp "$tmp/native/kotlin/OvKitStubs.kt" "$target/kit/OvKitStubs.kt"++cp "$tmp/native/kotlin/KitRenderer.kt" "$target/kit/KitRenderer.kt"++++echo "wrote $target/theme/VibeTheme.kt and $target/kit/{OvKit,OvKitStubs,KitRenderer}.kt"++++if [ "$check" = yes ]; then++ if ! git -C "$android" diff --quiet -- app/src/main/java/network/openvibe; then++ echo "the generated design files are stale: run scripts/gen-design.sh and commit the result" >&2++ git -C "$android" --no-pager diff --stat -- app/src/main/java/network/openvibe >&2++ exit 1++ fi++ echo "the generated design files match $version"++finnew file mode 100644@@@ -0,0 +1,33 @@++#!/bin/sh++# Screenshots of the four screens, taken on an emulator in CI. The screens that need an account are drawn from the++# debug-only PreviewActivity with sample data; the Pair screen is the real one.++#++# scripts/screenshots.sh [outdir]++set -eu++++out=${1:-shots}++mkdir -p "$out"++++adb wait-for-device++adb shell wm dismiss-keyguard || true++adb shell settings put global window_animation_scale 0 || true++adb shell settings put global transition_animation_scale 0 || true++adb shell settings put global animator_duration_scale 0 || true++++apk=$(find android/app/build/outputs/apk/debug -name "*.apk" | head -n 1)++adb install -r "$apk"++++for screen in pair approvals activity phone; do++ adb shell am force-stop network.openvibe.link || true++ adb shell am start -W -n network.openvibe.link/.PreviewActivity --es screen "$screen" >/dev/null++ sleep 2++ adb exec-out screencap -p > "$out/$screen.png"++ echo "shot $screen"++done++++# The launcher entry, as a person sees it first.++adb shell am force-stop network.openvibe.link || true++adb shell am start -W -n network.openvibe.link/.MainActivity >/dev/null++sleep 2++adb exec-out screencap -p > "$out/app.png"++echo "shot app"nnew file mode 100644@@@ -0,0 +1,18 @@++pluginManagement {++ repositories {++ google()++ mavenCentral()++ gradlePluginPortal()++ }++}++++dependencyResolutionManagement {++ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)++ repositories {++ google()++ mavenCentral()++ }++}++++rootProject.name = "OpenVibeLink"++include(":app")nnew file mode 100644@@@ -0,0 +1,132 @@++# Link on your phone (Android)++++OpenVibe Link for Android puts your account in your pocket: **Approvals** for what your agents asked while you were++away, **Activity** (the Dock's timeline), your phone as a **device** on your account, and **Send to OpenVibe** from any++app's share sheet.++++This is v0 of the phone build (plan 2026-11-07 §13.2). It has no phone automation: no AccessibilityService, no++MediaProjection, no reading the screen and no tapping for anyone. The only thing it does in the background is hold one++outbound connection, and only when you turn **Stay connected** on.++++## Install++++1. Download `openvibe-link-v<version>.apk` from the repository's Releases.++2. On the phone, allow installing from that source when Android asks.++3. Open **OpenVibe Link**.++++The APK is signed with the project's release key. A debug APK (`app-debug.apk`) is attached to CI runs and can be++installed the same way; Android allows it beside nothing else (the package is `network.openvibe.link`).++++## Pair the phone++++1. On a computer signed in as you, open **https://openvibe.bot/link/phone**.++2. The page shows a code and a QR. The code works once and lasts 15 minutes.++3. In the app, type the code or tap **Scan the QR instead**.++++Pairing is the Node's own: the code is redeemed on OpenVibe.Network, the phone gets a node credential, and it is bound++on OpenVibe.Bot as a device of kind **phone**. The credential never leaves the phone in the clear: it is encrypted with++a key the Android Keystore holds, and nothing on the phone is backed up.++++## The four screens++++- **Pair**: the code field and the scanner. A QR encodes `openvibe-link://pair?code=…&pairing=…`, which opens the app++ with the code filled in even from the phone's camera app. The app talks only to **https://openvibe.network** (a build++ constant, never a field): a QR or link that names another server is refused and says which.++- **Approvals**: Actor's pending approvals for you. Approve or decline. **Money, passwords, deletions and a send to a++ new recipient ask for a fingerprint, a face or the device credential first** (BIOMETRIC_STRONG, with the device++ credential behind it on Android 11+), and the answer is only sent after it succeeds.++- **Activity**: the Dock's timeline, read-only.++- **This phone**: what the phone is paired as, **Stay connected** (a foreground service with a notification you can++ always see and stop), sign-in, and **Unpair**.++++Approvals and Activity need your OpenVibe account as well as the pairing: tap **Sign in to OpenVibe**. That is a public++OAuth client (`link`) with PKCE S256 and no secret in the APK. The sign-in returns to++`https://openvibe.network/link/auth`, an **Android App Link** claimed for this package (Network publishes++`/.well-known/assetlinks.json` with the release certificate's SHA-256), so no other app can answer the redirect; the++chooser always shows what the app does, and its token is your own session with your non-staff role.++++Until the owner's release key exists (`LINK_ANDROID_CERT_SHA256` on Network), Android verifies no app for that link and++the client gets Network's read-only Dock token instead of a session: the app still pairs, and signing in is what waits++for the key. A debug build returns on `openvibe-link://auth` and always gets the Dock token.++++The tokens and the pairing are AES-GCM-sealed with a key the Android Keystore makes and never exports, nothing is++logged, and the app's data is excluded from Android backups.++++### The screens++++Rendered from the Composables by Paparazzi (Pixel 5, dark; every screen is also recorded in light). The pictures come++from `app/src/test/java/network/openvibe/link/ScreensSnapshotTest.kt` and the PreviewActivity's sample data:++`gradle :app:recordPaparazziDebug` writes them to `app/src/test/snapshots/images/`, and `verifyPaparazziDebug` fails++when a screen changes without a new recording.++++The look (Opus, 11-07) is OpenVibe's: Vibe's tokens from openvibe-shared's native export, with Material 3's container++roles mapped to Vibe surfaces (`ui/Theme.kt`; Shared 4.140.0 does the same in the export), and a small kit in++`ui/Kit.kt`:++- a page with its title and one line;++- cards on the raised surface with a hairline;++- chips whose tone says how much a thing matters: money amber, deleting red, passwords violet, a new recipient blue;++- still status dots;++- still skeletons while the first answer loads (nothing spins);++- empty states that say what will appear.++++| Pair | Approvals |++|---|---|++|  |  |++++| Activity | This phone |++|---|---|++|  |  |++++The CI's emulator job (`screenshots`) also takes the four on a real emulator, non-blocking beside the build. The app++has a debug-only `PreviewActivity` that draws one screen with the same sample data without an account:++`adb shell am start -n network.openvibe.link/.PreviewActivity --es screen approvals`.++++## Send to OpenVibe++++Share a file or a photo from any app and pick **Send to OpenVibe**. The sheet asks first — **Send** starts the upload,++**Cancel** sends nothing. It goes to your **Download** drive++(openvibe.download) through the drive's own upload (the same three steps the web page makes: start, parts, complete),++with progress in a notification. Nothing is re-encoded. A shared text becomes a small `.txt` named after its first++line.++++## Build it++++The app is in `android/`; it shares the Node's Go core through `gomobile bind`, so pairing, token refresh and++reconnects are the same code the desktop Node runs. A debug build may be pointed at another server with++`./gradlew :app:assembleDebug -Pnetwork.url=https://staging.example`; that build shows a **Debug server** banner on every++screen, and a release build always carries the official one.++++```sh++# 1. Generate the design system from the pinned openvibe-shared release (never hand-edit these files).++sh android/scripts/gen-design.sh++++# 2. Bind the Go core into android/app/libs/core.aar (needs an Android NDK).++ANDROID_HOME=~/Android/Sdk sh android/scripts/build-core.sh++++# 3. Build and test.++gradle -p android :app:testDebugUnitTest++gradle -p android :app:assembleDebug++```++++Versions are pinned: Gradle 8.11.1 (`android/gradle/wrapper/gradle-wrapper.properties` and the CI), Android Gradle++Plugin 8.7.3 and Kotlin 2.0.21 (`android/build.gradle.kts`), gomobile and the openvibe-shared release in the two++scripts. `android/design-version.txt` names the shared release; the CI refuses a stale generated theme.++++CI (`.github/workflows/android.yml`) builds the core, runs the unit tests, builds the debug APK and attaches a signed++release APK on a tag when the repository has the signing key (`ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`,++`ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`). With no key it says so and skips the signed build; it never generates one.++A second, non-blocking job takes screenshots on an emulator.++++## Tests++++- `android/app/src/test`: the approval categories that need biometrics (`ApprovalRulesTest`), the share-intent++ parsing (`ShareIntentsTest`), the pairing links and PKCE (`PairLinksTest`).++- `go test ./mobile`: pairing against a fake Network and Bot.++- The rest of the Go tests are unchanged.++++## What v0 does not do++++- No phone automation (plan §13.2's opt-in Accessibility Service and MediaProjection are a later, separate build).++- No Google Play, no Firebase, no third-party account.++- No iOS yet.++- Activity is read-only; answering happens in Approvals.nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/activity.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/approvals.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/pair.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/phone.png differnnew file mode 100644@@@ -0,0 +1,278 @@++// Package mobile is the Link phone's Go core. It is the same pairing and connection code the Node runs++// (internal/link), bound with `gomobile bind` into an AAR the Android app links against, so the phone pairs and++// stays connected exactly as `openvibe-node pair --network … --pairing … --code …` does.++//++// The core keeps no secret on disk: Pair returns the credentials as JSON for the app to encrypt with a key in the++// Android Keystore, and every later call takes that JSON back. Nothing here logs a credential or a token: the link's++// logger is discarded and the status strings carry only what the person needs to read.++//++// The exported surface is deliberately small and gomobile-friendly: strings, errors and one callback interface.++package mobile++++import (++ "context"++ "encoding/json"++ "errors"++ "io"++ "log/slog"++ "sync"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"++ "github.com/OpenVibers/OpenVibe.Node/internal/link"++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"++)++++// Version is what the phone tells the services it is.++const Version = "0.1.0"++++// DeviceKind is how the phone shows up on the person's account (OpenVibe.Bot's devices).++const DeviceKind = "phone"++++const userAgent = "openvibe-link/" + Version++++// pairing is the credential bundle Pair and Bind return as JSON. The field names match the ones the CLI stores, so++// a credential file from a phone could be read by the Node and the other way round.++type pairing struct {++ Principal string `json:"principal"`++ NodeCredential string `json:"node_credential"`++ Network string `json:"network"`++ DeviceID string `json:"device_id,omitempty"`++ RobotID string `json:"robot_id,omitempty"`++ Server string `json:"server"`++ PairedFor *protocol.PairedFor `json:"paired_for,omitempty"`++ PairedAt time.Time `json:"paired_at"`++}++++// Listener hears the link's state. Called from the link's goroutines, one at a time.++type Listener interface {++ // OnState receives JSON: {"state":"connected"|"offline"|"stopped"|"revoked","detail":"…"}.++ OnState(event string)++}++++// NormalizeCode accepts what a person types (abcd 1234) and returns the Crockford form ABCD1234.++func NormalizeCode(code string) (string, error) {++ return link.NormalizeCode(code)++}++++// Pair redeems a pairing code on OpenVibe.Network and binds the phone as a device on OpenVibe.Bot. It returns the++// credential bundle as JSON. A bind that fails is not fatal: Start binds again, exactly as the Node does.++func Pair(networkURL, botURL, code, name, pairingID string) (string, error) {++ code, err := link.NormalizeCode(code)++ if err != nil {++ return "", err++ }++ // The QR on openvibe.bot/link/phone carries the pair_… id, so a wrong code is counted against that pairing's code,++ // exactly as `openvibe-node pair --network … --pairing … --code …` does. A typed code may omit it.++ if pairingID != "" && !link.PairingIDRe.MatchString(pairingID) {++ return "", errors.New("the pairing id is not valid; scan the QR again for a fresh code")++ }++ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)++ defer cancel()++ c, err := link.PairNetwork(ctx, nil, networkURL, protocol.NodePairingRequest{Code: code, Pairing: pairingID, Name: name}, userAgent)++ if err != nil {++ return "", err++ }++ c.Server = trimSlash(botURL)++ if c.Server == "" {++ c.Server = "https://openvibe.bot"++ }++ p := pairingOf(c)++ if bound, err := link.Bind(ctx, nil, c, link.NewTokenSource(c, nil, userAgent), userAgent); err == nil {++ p = pairingOf(bound)++ } else if errors.Is(err, link.ErrNodeRevoked) {++ return "", err++ }++ return marshalPairing(p)++}++++// Bind binds a paired phone whose device record is missing (the bind at pairing failed). It returns the bundle.++func Bind(credsJSON string) (string, error) {++ c, err := parsePairing(credsJSON)++ if err != nil {++ return "", err++ }++ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)++ defer cancel()++ bound, err := link.Bind(ctx, nil, c, link.NewTokenSource(c, nil, userAgent), userAgent)++ if err != nil {++ return "", err++ }++ return marshalPairing(pairingOf(bound))++}++++// Info is what the "This phone" screen shows, as JSON: the device, its account and its binding state.++func Info(credsJSON string) (string, error) {++ p, err := parsePairingJSON(credsJSON)++ if err != nil {++ return "", err++ }++ out, _ := json.Marshal(struct {++ DeviceID string `json:"device_id"`++ Principal string `json:"principal"`++ Server string `json:"server"`++ Network string `json:"network"`++ Kind string `json:"kind"`++ Bound bool `json:"bound"`++ }{p.DeviceID, p.Principal, p.Server, p.Network, DeviceKind, p.DeviceID != ""})++ return string(out), nil++}++++// Link keeps the phone's one outbound connection to OpenVibe.Bot alive while it runs.++type Link struct {++ mu sync.Mutex++ h *handler++ l *link.Link++ cancel context.CancelFunc++ done chan struct{}++}++++// NewLink prepares a connection from a credential bundle. Start dials; nothing is sent before a pairing exists.++func NewLink(credsJSON string) (*Link, error) {++ c, err := parsePairing(credsJSON)++ if err != nil {++ return nil, err++ }++ u := c.DeviceURL++ if u == "" {++ if u, err = link.DeviceURL(c.Server); err != nil {++ return nil, err++ }++ }++ h := &handler{}++ if c.NetworkPaired() {++ l := link.New(link.Options{++ URL: u, Tokens: link.NewTokenSource(c, nil, userAgent), UserAgent: userAgent,++ Log: slog.New(slog.NewTextHandler(io.Discard, nil)),++ }, h)++ return &Link{h: h, l: l}, nil++ }++ l := link.New(link.Options{++ URL: u, Credential: c.Credential, UserAgent: userAgent,++ Log: slog.New(slog.NewTextHandler(io.Discard, nil)),++ }, h)++ return &Link{h: h, l: l}, nil++}++++// Start runs the link until Stop (or a revocation that retrying cannot fix). It returns immediately.++func (l *Link) Start(listener Listener) error {++ l.mu.Lock()++ defer l.mu.Unlock()++ if l.cancel != nil {++ return nil++ }++ l.h.setListener(listener)++ ctx, cancel := context.WithCancel(context.Background())++ l.cancel, l.done = cancel, make(chan struct{})++ go func() {++ defer close(l.done)++ if err := l.l.Run(ctx); err != nil {++ emit(l.h.listener(), "revoked", err.Error())++ } else {++ emit(l.h.listener(), "stopped", "")++ }++ }()++ return nil++}++++// Stop closes the connection. It is safe to call when the link is not running.++func (l *Link) Stop() {++ l.mu.Lock()++ cancel, done := l.cancel, l.done++ l.cancel, l.done = nil, nil++ l.mu.Unlock()++ if cancel == nil {++ return++ }++ cancel()++ <-done++}++++// Status is the connection's state as JSON.++func (l *Link) Status() string {++ st := l.l.Stats()++ out, _ := json.Marshal(struct {++ Connected bool `json:"connected"`++ Reconnects int64 `json:"reconnects"`++ LastError string `json:"last_error"`++ }{l.l.Connected(), st.Reconnects, st.LastError})++ return string(out)++}++++// handler is the link's callbacks, passed on to the app's listener.++type handler struct {++ mu sync.Mutex++ lis Listener++}++++func (h *handler) setListener(l Listener) { h.mu.Lock(); h.lis = l; h.mu.Unlock() }++++func (h *handler) listener() Listener { h.mu.Lock(); defer h.mu.Unlock(); return h.lis }++++func (h *handler) Connected() { emit(h.listener(), "connected", "") }++++func (h *handler) Frame(protocol.Frame) {}++++func (h *handler) Disconnected(err error) {++ detail := ""++ if err != nil {++ detail = err.Error()++ }++ emit(h.listener(), "offline", detail)++}++++func emit(l Listener, state, detail string) {++ if l == nil {++ return++ }++ b, _ := json.Marshal(struct {++ State string `json:"state"`++ Detail string `json:"detail,omitempty"`++ }{state, detail})++ l.OnState(string(b))++}++++func pairingOf(c *credentials.Credentials) pairing {++ return pairing{++ Principal: c.Principal, NodeCredential: c.NodeCredential.Reveal(), Network: c.Network,++ DeviceID: c.DeviceID, RobotID: c.RobotID, Server: c.Server, PairedFor: c.PairedFor, PairedAt: c.PairedAt,++ }++}++++func marshalPairing(p pairing) (string, error) {++ b, err := json.Marshal(p)++ return string(b), err++}++++func parsePairingJSON(s string) (pairing, error) {++ var p pairing++ if err := json.Unmarshal([]byte(s), &p); err != nil {++ return p, errors.New("the pairing is not valid")++ }++ return p, nil++}++++func parsePairing(s string) (*credentials.Credentials, error) {++ p, err := parsePairingJSON(s)++ if err != nil {++ return nil, err++ }++ if p.Principal == "" || p.NodeCredential == "" || p.Network == "" {++ return nil, errors.New("this phone is not paired")++ }++ c := &credentials.Credentials{++ Principal: p.Principal, NodeCredential: credentials.NewSecret(p.NodeCredential), Network: trimSlash(p.Network),++ DeviceID: p.DeviceID, RobotID: p.RobotID, Server: trimSlash(p.Server), PairedFor: p.PairedFor, PairedAt: p.PairedAt,++ }++ if c.Server == "" {++ c.Server = "https://openvibe.bot"++ }++ return c, nil++}++++func trimSlash(s string) string {++ for len(s) > 0 && s[len(s)-1] == '/' {++ s = s[:len(s)-1]++ }++ return s++}nnew file mode 100644@@@ -0,0 +1,148 @@++package mobile++++import (++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "strings"++ "testing"++)++++// fakeNetwork and fakeBot are the two services a phone pairs through, reduced to the answers Pair reads.++func fakeNetwork(t *testing.T, principal, credential, ref string) (*httptest.Server, *[]string) {++ var codes []string++ n := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ switch r.URL.Path {++ case "/api/v1/node-pairing":++ var body struct {++ Code string `json:"code"`++ Pairing string `json:"pairing"`++ Name string `json:"name"`++ }++ _ = json.NewDecoder(r.Body).Decode(&body)++ codes = append(codes, body.Code)++ if body.Pairing != "" && body.Pairing != "pair_01JD2C3D4E5F6G7H8J9K0MNPQR" {++ w.WriteHeader(http.StatusUnprocessableEntity)++ _, _ = w.Write([]byte(`{"code":"registry.invalid_pairing_request","detail":"bad pairing id"}`))++ return++ }++ if body.Code != "ABCD2345" {++ w.WriteHeader(http.StatusForbidden)++ _, _ = w.Write([]byte(`{"code":"registry.pairing_code_invalid","detail":"that is not the pairing code"}`))++ return++ }++ _ = json.NewEncoder(w).Encode(map[string]any{++ "principal": principal, "credential": credential,++ "paired_for": map[string]string{"service": "bot", "ref": ref},++ })++ case "/oauth/token":++ _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "node-token", "expires_in": 300})++ default:++ http.NotFound(w, r)++ }++ }))++ t.Cleanup(n.Close)++ return n, &codes++}++++func fakeBot(t *testing.T) *httptest.Server {++ b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ if r.URL.Path != "/api/v1/devices/bind" || r.Header.Get("Authorization") != "Bearer node-token" {++ http.Error(w, "no", http.StatusUnauthorized)++ return++ }++ _ = json.NewEncoder(w).Encode(map[string]any{"device_id": "dev_phone"})++ }))++ t.Cleanup(b.Close)++ return b++}++++func TestPairBindsThePhone(t *testing.T) {++ net, codes := fakeNetwork(t, "nod_01234567890123456789012345", "node-secret", "phone:usr_01")++ bot := fakeBot(t)++ creds, err := Pair(net.URL, bot.URL, "abcd-2345", "Phone", "")++ if err != nil {++ t.Fatalf("Pair: %v", err)++ }++ if len(*codes) != 1 || (*codes)[0] != "ABCD2345" {++ t.Fatalf("the code sent was %v, want the normalized ABCD2345", *codes)++ }++ var p pairing++ if err := json.Unmarshal([]byte(creds), &p); err != nil {++ t.Fatalf("Pair returned no JSON: %v", err)++ }++ if p.DeviceID != "dev_phone" || p.Principal != "nod_01234567890123456789012345" || p.NodeCredential != "node-secret" {++ t.Fatalf("pairing = %+v", p)++ }++ if p.Server != bot.URL || p.Network != net.URL {++ t.Fatalf("origins = %q %q", p.Server, p.Network)++ }++ if p.PairedFor == nil || p.PairedFor.Service != "bot" {++ t.Fatalf("paired_for = %+v", p.PairedFor)++ }++}++++func TestPairRefusesAWrongCode(t *testing.T) {++ net, _ := fakeNetwork(t, "nod_01234567890123456789012345", "node-secret", "phone:usr_01")++ bot := fakeBot(t)++ _, err := Pair(net.URL, bot.URL, "ZZZZ-9999", "Phone", "")++ if err == nil || !strings.Contains(err.Error(), "pairing code") {++ t.Fatalf("a wrong code gave %v", err)++ }++}++++func TestPairRejectsAMalformedCode(t *testing.T) {++ if _, err := Pair("https://openvibe.network", "https://openvibe.bot", "nope", "Phone", ""); err == nil {++ t.Fatal("a malformed code was accepted")++ }++}++++func TestInfoNamesThePhone(t *testing.T) {++ creds := `{"principal":"nod_01234567890123456789012345","node_credential":"s","network":"https://openvibe.network","device_id":"dev_phone","server":"https://openvibe.bot"}`++ got, err := Info(creds)++ if err != nil {++ t.Fatalf("Info: %v", err)++ }++ var out struct {++ DeviceID string `json:"device_id"`++ Kind string `json:"kind"`++ Bound bool `json:"bound"`++ }++ if err := json.Unmarshal([]byte(got), &out); err != nil {++ t.Fatal(err)++ }++ if out.DeviceID != "dev_phone" || out.Kind != "phone" || !out.Bound {++ t.Fatalf("info = %+v", out)++ }++}++++func TestNewLinkNeedsAPairing(t *testing.T) {++ if _, err := NewLink(`{}`); err == nil {++ t.Fatal("an empty pairing made a link")++ }++ if _, err := NewLink(`not json`); err == nil {++ t.Fatal("invalid JSON made a link")++ }++}++++func TestNewLinkUsesTheBotSocket(t *testing.T) {++ l, err := NewLink(`{"principal":"nod_01234567890123456789012345","node_credential":"s","network":"https://openvibe.network","server":"https://openvibe.bot"}`)++ if err != nil {++ t.Fatalf("NewLink: %v", err)++ }++ l.Stop() // Stop before Start is harmless.++ if got := l.Status(); !strings.Contains(got, "connected") {++ t.Fatalf("status = %s", got)++ }++}++++func TestPairCarriesThePairingIDFromTheQR(t *testing.T) {++ net, _ := fakeNetwork(t, "nod_01234567890123456789012345", "node-secret", "phone:usr_01")++ bot := fakeBot(t)++ if _, err := Pair(net.URL, bot.URL, "abcd-2345", "Phone", "pair_01JD2C3D4E5F6G7H8J9K0MNPQR"); err != nil {++ t.Fatalf("Pair with a pairing id: %v", err)++ }++ if _, err := Pair(net.URL, bot.URL, "abcd-2345", "Phone", "not-a-pairing"); err == nil {++ t.Fatal("a malformed pairing id was accepted")++ }++}
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+name: android
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ apk:
+ name: Go core, unit tests and the APK
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # the APK is attached to the release on a tag
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
+ with:
+ distribution: temurin
+ java-version: "17"
+ - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
+ with:
+ go-version-file: go.mod
+ - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
+ with:
+ node-version: "20"
+ - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
+ with:
+ gradle-version: "8.11.1"
+
+ - name: Android NDK (gomobile needs one)
+ run: |
+ ndk=27.0.12077973
+ yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ndk" >/dev/null
+ echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$ndk" >> "$GITHUB_ENV"
+
+ - name: Bind the Go core (gomobile, pinned)
+ run: sh android/scripts/build-core.sh
+
+ - name: The generated design files are current
+ run: sh android/scripts/gen-design.sh --check
+
+ - name: Unit tests
+ run: gradle -p android :app:testDebugUnitTest
+
+ - name: Debug APK
+ run: gradle -p android :app:assembleDebug
+
+ # A release APK is signed only when the runner has a key. The key is a repo secret; when it is absent the
+ # step says so and is skipped, and no key is generated here.
+ - name: The release key
+ if: startsWith(github.ref, 'refs/tags/')
+ env:
+ KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ if [ -z "$KEYSTORE" ]; then
+ echo "::warning::ANDROID_KEYSTORE_BASE64 is not set for this repository: the signed release APK is skipped, and no key is invented."
+ echo "LINK_SIGNED=0" >> "$GITHUB_ENV"
+ exit 0
+ fi
+ printf '%s' "$KEYSTORE" | base64 -d > android/release.keystore
+ {
+ echo "storeFile=../release.keystore"
+ echo "storePassword=$STORE_PASSWORD"
+ echo "keyAlias=$KEY_ALIAS"
+ echo "keyPassword=$KEY_PASSWORD"
+ } > android/keystore.properties
+ chmod 600 android/release.keystore android/keystore.properties
+ echo "LINK_SIGNED=1" >> "$GITHUB_ENV"
+
+ - name: Release APK
+ if: startsWith(github.ref, 'refs/tags/') && env.LINK_SIGNED == '1'
+ run: gradle -p android :app:assembleRelease
+
+ - name: APK sizes
+ run: |
+ for f in android/app/build/outputs/apk/debug/*.apk android/app/build/outputs/apk/release/*.apk; do
+ [ -f "$f" ] || continue
+ size=$(du -m "$f" | cut -f1)
+ echo "| $f | ${size} MiB |" >> "$GITHUB_STEP_SUMMARY"
+ echo "$f: ${size} MiB"
+ done
+
+ - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
+ with:
+ name: openvibe-link-debug
+ path: android/app/build/outputs/apk/debug/*.apk
+ if-no-files-found: error
+
+ - name: Attach the APK to the release
+ if: startsWith(github.ref, 'refs/tags/') && env.LINK_SIGNED == '1'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "OpenVibe Link for Android."
+ gh release upload "$GITHUB_REF_NAME" "android/app/build/outputs/apk/release/app-release.apk#openvibe-link-$GITHUB_REF_NAME.apk" --clobber
+
+ screens:
+ name: emulator screenshots (not blocking)
+ needs: apk
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ with:
+ name: openvibe-link-debug
+ path: android/app/build/outputs/apk/debug
+ - name: Let the runner use the emulator's KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
+ with:
+ api-level: 34
+ arch: x86_64
+ target: google_apis
+ disable-animations: true
+ script: sh android/scripts/screenshots.sh shots
+ - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
+ if: always()
+ with:
+ name: screenshots
+ path: shots/
+ if-no-files-found: warn
@@ -7,3 +7,13 @@ __pycache__/
@@ -7,3 +7,13 @@ __pycache__/
*.egg-info/
*.egg-info/
/build/
/build/
esp32/test/.deps/
esp32/test/.deps/
+
+# Android (android/): the AAR is bound by android/scripts/build-core.sh, never committed.
+android/.gradle/
+android/build/
+android/app/build/
+android/app/libs/
+android/local.properties
+android/keystore.properties
+android/.kotlin/
+*.aar
@@ -106,6 +106,12 @@ tasks this machine ran today, and what happened, and lets you pause everything,
@@ -106,6 +106,12 @@ tasks this machine ran today, and what happened, and lets you pause everything,
share a port, or send files (not in this version). It is served only on loopback, with a per-run token in the address
share a port, or send files (not in this version). It is served only on loopback, with a per-run token in the address
fragment that every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.
fragment that every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.
+**Link on your phone (Android, v0):** the same pairing and connection as the desktop Node, in an app: your agents'
+approvals in your pocket (money, passwords, deletions and a send to a new recipient ask for a fingerprint first), the
+Dock's activity read-only, and **Send to OpenVibe** from any app's share sheet to your Download drive. It pairs from
+openvibe.bot/link/phone and shows up as a device of kind `phone`. No phone automation at all in this version: no
+AccessibilityService, no screen capture. Build, install and the security notes: [docs/android.md](docs/android.md).
+
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
[panel](docs/panel.md) (Link's own window and its security rules),
[panel](docs/panel.md) (Link's own window and its security rules),
@@ -24,7 +24,8 @@
@@ -24,7 +24,8 @@
"one-command install on a Raspberry Pi or other Linux robot (I2C, SPI, camera tools, kit detection)",
"one-command install on a Raspberry Pi or other Linux robot (I2C, SPI, camera tools, kit detection)",
"system service on systemd, launchd and Windows (or a Windows sign-in start)",
"system service on systemd, launchd and Windows (or a Windows sign-in start)",
"ESP32 library (OpenVibeNode): pairing, control link, drive and actuator commands, telemetry, heartbeat, deadman and e-stop",
"ESP32 library (OpenVibeNode): pairing, control link, drive and actuator commands, telemetry, heartbeat, deadman and e-stop",
- "`openvibe-node exchange serve --model <id>`: serve a local Ollama/llama.cpp/vLLM model through an OpenAI-compatible listener and register it on OpenVibe.Exchange's endpoint-seller API with the person's OPENVIBE_DEVELOPER_KEY, then remove the offer on Ctrl-C"
+ "`openvibe-node exchange serve --model <id>`: serve a local Ollama/llama.cpp/vLLM model through an OpenAI-compatible listener and register it on OpenVibe.Exchange's endpoint-seller API with the person's OPENVIBE_DEVELOPER_KEY, then remove the offer on Ctrl-C",
+ "Link for Android (v0, android/): the same Go pairing and connection bound with gomobile, a Kotlin + Jetpack Compose app (Pair, Approvals with a fingerprint for money, passwords, deletions and new recipients, Activity, This phone), Stay connected as an opt-in foreground service, and Send to OpenVibe on the share sheet; no phone automation"
],
],
"notYet": [
"notYet": [
"code signing and notarization for Windows and macOS (the release has SHA256SUMS only)",
"code signing and notarization for Windows and macOS (the release has SHA256SUMS only)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ // Screenshots of every screen from the sample data (src/test/.../ScreensSnapshotTest.kt).
+ id("app.cash.paparazzi")
+}
+
+// The Go core, bound by gomobile (android/scripts/build-core.sh) into android/app/libs/core.aar. It is built, never
+// committed; the build says so instead of failing with an unresolved symbol.
+val coreAar = layout.projectDirectory.file("libs/core.aar")
+if (!coreAar.asFile.exists()) {
+ throw GradleException("android/app/libs/core.aar is missing: run android/scripts/build-core.sh first (it needs the Android NDK).")
+}
+
+// A release build is signed only when the runner has a key (repo secrets): GH Actions writes android/keystore.properties.
+val signingProps = rootProject.file("keystore.properties").takeIf { it.exists() }
+ ?.let { Properties().apply { it.inputStream().use(::load) } }
+
+// The server a build talks to. A release build always carries https://openvibe.network; a debug build may be pointed
+// elsewhere (`-Pnetwork.url=https://staging.example`) and then says so in a banner on every screen. A QR or a link that
+// names another server is refused, never followed (data/Servers.kt, data/PairLinks.kt).
+val officialServer = "https://openvibe.network"
+val networkUrl = (project.findProperty("network.url") as String?)?.trimEnd('/') ?: officialServer
+
+android {
+ namespace = "network.openvibe.link"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "network.openvibe.link"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ signingConfigs {
+ if (signingProps != null) {
+ create("release") {
+ storeFile = file(signingProps.getProperty("storeFile"))
+ storePassword = signingProps.getProperty("storePassword")
+ keyAlias = signingProps.getProperty("keyAlias")
+ keyPassword = signingProps.getProperty("keyPassword")
+ }
+ }
+ }
+
+ buildTypes {
+ debug {
+ buildConfigField("String", "NETWORK_URL", "\"$networkUrl\"")
+ buildConfigField("boolean", "DEBUG_SERVER", "${networkUrl != officialServer}")
+ }
+ release {
+ buildConfigField("String", "NETWORK_URL", "\"$officialServer\"")
+ buildConfigField("boolean", "DEBUG_SERVER", "false")
+ isMinifyEnabled = false
+ if (signingProps != null) signingConfig = signingConfigs.getByName("release")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
+
+ packaging {
+ resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}", "/META-INF/DEPENDENCIES")
+ }
+}
+
+dependencies {
+ implementation(files("libs/core.aar"))
+
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+
+ val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ // The generated kit (OvKit.kt) names Icons.Filled.Error and Icons.Filled.ExpandMore, which are in the extended set.
+ implementation("androidx.compose.material:material-icons-extended")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("androidx.biometric:biometric:1.1.0")
+ implementation("androidx.browser:browser:1.8.0")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+
+ // The QR the web shows: the same library Bot's link page builds the code with, in an embedded scanner.
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
+
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <!-- Debug only: the screenshot job starts one screen with sample data. Never in a release APK. -->
+ <application>
+ <activity
+ android:name=".PreviewActivity"
+ android:exported="true"
+ android:theme="@style/Theme.OpenVibe" />
+ </application>
+</manifest>
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+package network.openvibe.link
+
+import android.os.Bundle
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import network.openvibe.link.data.ActionItem
+import network.openvibe.link.ui.ActivityScreen
+import network.openvibe.link.ui.ApprovalsScreen
+import network.openvibe.link.ui.LinkTheme
+import network.openvibe.link.ui.PairScreen
+import network.openvibe.link.ui.PhoneScreen
+import org.json.JSONObject
+
+/**
+ * Debug builds only: draws one screen with sample data, so a screenshot job (or a designer) can see every screen
+ * without an account. It is not in a release APK: it lives in src/debug.
+ *
+ * adb shell am start -n network.openvibe.link/.PreviewActivity --es screen approvals
+ */
+class PreviewActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val screen = intent.getStringExtra("screen") ?: "approvals"
+ setContent {
+ LinkTheme {
+ // The theme's own background: the screens assume the app's Scaffold paints it.
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ when (screen) {
+ "activity" -> ActivityScreen(preview = SAMPLE_ACTIVITY)
+ "phone" -> PhoneScreen(previewInfo = SAMPLE_DEVICE, preview = true)
+ "pair" -> PairScreen(initialCode = "ABCD-1234")
+ else -> ApprovalsScreen(preview = SAMPLE_APPROVALS)
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private fun item(
+ id: String,
+ title: String,
+ summary: String?,
+ bucket: String = "waiting",
+ kind: String = "approval",
+ agent: String? = "Actor",
+ at: String = "2026-11-07T09:24:00Z",
+ ) = ActionItem(
+ service = "actor", id = id, kind = kind, title = title, summary = summary, bucket = bucket,
+ state = if (bucket == "waiting") "pending" else "done", agent = agent,
+ createdAt = at, updatedAt = at, actions = listOf("approve", "decline", "open"),
+ )
+
+ val SAMPLE_APPROVALS = listOf(
+ item("apr_1", "Renew the domain for another year", "It would charge 14 USD to the card on file."),
+ item("apr_2", "Use the password vault to sign in to the registrar", "The agent needs the saved password."),
+ item("apr_3", "Delete the old nightly backups", "Twelve archives from last year would be removed."),
+ item("apr_4", "Email the finished report to Dana", "Dana is not in your contacts yet."),
+ item("apr_5", "Write a summary of yesterday's notes", null),
+ )
+
+ val SAMPLE_ACTIVITY = listOf(
+ item("tsk_1", "Reviewing your pull request", "Step 3 of 7 · reading the diff", bucket = "running", kind = "agent_task", at = "2026-11-07T09:31:00Z"),
+ item("tsk_2", "Monthly invoice for OpenVibe.Host", "Waiting for you: money", bucket = "waiting", kind = "agent_task", at = "2026-11-07T09:20:00Z"),
+ item("tsk_3", "Nightly catch-up", "Runs at 03:00 on your computer", bucket = "scheduled", kind = "agent_task", at = "2026-11-07T03:00:00Z"),
+ item("tsk_4", "Deploy the staging build", "Finished in 4m 12s", bucket = "done", kind = "agent_task", at = "2026-11-06T18:04:00Z"),
+ item("tsk_5", "Index the photo library", "Stopped: the drive was full", bucket = "failed", kind = "agent_task", at = "2026-11-06T12:10:00Z"),
+ )
+
+ val SAMPLE_DEVICE = JSONObject()
+ .put("device_id", "dev_01J9Z8K4Q7X2M5N8P3R6T0V")
+ .put("principal", "nod_01J9Z8K4Q7X2M5N8P3R6T0V")
+ .put("server", "https://openvibe.bot")
+ .put("network", "https://openvibe.network")
+ .put("kind", "phone")
+ .put("bound", true)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.CAMERA" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
+ <uses-permission android:name="android.permission.USE_BIOMETRIC" />
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+
+ <uses-feature android:name="android.hardware.camera" android:required="false" />
+
+ <application
+ android:name=".LinkApplication"
+ android:allowBackup="false"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:fullBackupContent="false"
+ android:icon="@drawable/ic_launcher"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.OpenVibe">
+
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ <!-- The QR on openvibe.bot/link/phone opens the app with the code filled in. -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="openvibe-link" android:host="pair" />
+ </intent-filter>
+ <!-- The sign-in returns here: an https App Link, claimed for this package by
+ https://openvibe.network/.well-known/assetlinks.json (server/auth/app-links.js in OpenVibe.Network),
+ so no other app can answer the redirect. A custom scheme could be registered by any app, so there is
+ none for sign-in: openvibe-link:// carries pair codes only. The code is exchanged over PKCE and
+ never carries a secret. -->
+ <intent-filter android:autoVerify="true">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="https" android:host="openvibe.network" android:pathPrefix="/link/auth" />
+ </intent-filter>
+ </activity>
+
+ <!-- "Send to OpenVibe" from any app. -->
+ <activity
+ android:name=".ShareActivity"
+ android:exported="true"
+ android:label="@string/share_label"
+ android:theme="@style/Theme.OpenVibe.Translucent">
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND_MULTIPLE" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ </activity>
+
+ <service
+ android:name=".service.StayConnectedService"
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
+
+ <service
+ android:name=".service.ShareUploadService"
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
+ </application>
+</manifest>
new file mode 100644
new file mode 100644
@@ -0,0 +1,649 @@
@@ -0,0 +1,649 @@
+// The OpenVibe kit for Jetpack Compose: a kit document, rendered.
+//
+// A kit document is a JSON tree of { type, props, children } (openvibe-shared/components). The web draws it with
+// kit.js; this file draws the same document with the kit's own Compose views (OvKit.kt), wearing the theme's tokens
+// (the Material 3 colour scheme and OvScale).
+//
+// val doc = KitNode.parse(json)
+// MaterialTheme(colorScheme = VibeDarkColorScheme) {
+// KitView(doc) { action -> open(action) }
+// }
+//
+// The JSON is read with org.json, the parser Android carries in the platform.
+package network.openvibe.kit
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import network.openvibe.theme.OvScale
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** What a tap asks the app to do: open a link, or run the action a button names. */
+sealed class KitAction {
+ /** Follow a link (a card, a row, a button with an href). */
+ data class Open(val href: String) : KitAction()
+
+ /** Run the action a button names (the app decides what that means). */
+ data class Run(val id: String) : KitAction()
+
+ /** Share a link: the app opens the platform's own share sheet (Android's chooser, with Direct Share). */
+ data class Share(val title: String, val url: String) : KitAction()
+}
+
+/** A link the kit draws: where it goes and what it says. */
+data class KitLink(val href: String, val label: String)
+
+/** One tab: its key (what the web puts in the URL), its label and its count. */
+data class KitTab(val key: String, val label: String, val count: Int? = null)
+
+/** One row of a sidebar's navigation: where it goes, its name, its icon, whether you are on it, and a count. */
+data class KitNav(val href: String, val label: String, val icon: String? = null, val here: Boolean = false, val count: Int? = null)
+
+/** One number with its label. */
+data class KitStat(val value: String, val label: String, val href: String? = null)
+
+/**
+ * A node of a kit document: one component, with its props and its children.
+ *
+ * The sealed cases are the kit's components; every one of them has a reference view in OvKit.kt that this file
+ * draws with. A new component in components.js needs a case here (and a view), or the kit check fails.
+ */
+sealed class KitNode {
+ /** The component's type, as it is written in the document. */
+ abstract val type: String
+
+ /** The component's children, in document order. */
+ abstract val children: List<KitNode>
+
+ /** A button: one action, a link or a run. */
+ data class Button(
+ val label: String,
+ val href: String? = null,
+ val icon: String? = null,
+ val kind: OvKind = OvKind.Primary,
+ val small: Boolean = false,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "button"
+ }
+
+ /** A card: one thing, the whole card is one link. */
+ data class Card(
+ val href: String,
+ val title: String,
+ val variant: String? = null,
+ val ratio: Float? = null,
+ val image: String? = null,
+ val alt: String? = null,
+ val badge: String? = null,
+ val live: Boolean = false,
+ val corner: String? = null,
+ val sub: String? = null,
+ val preview: String? = null,
+ val who: String? = null,
+ val avatar: String? = null,
+ val count: String? = null,
+ val shownAt: String? = null,
+ val level: Int? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "card"
+
+ /** The media's shape, as the view takes it. */
+ val ratioValue: Float get() = ratio ?: 16f / 9f
+
+ /** The line under the title: who, the count, when. */
+ val metaText: String?
+ get() = listOfNotNull(who, count, shownAt).joinToString(" · ").ifEmpty { null }
+
+ /** The badge: the one the document gives, or LIVE. */
+ val badgeText: String? get() = badge ?: if (live) "LIVE" else null
+ }
+
+ /** A rail: a row of cards that scrolls sideways. */
+ data class Rail(
+ val title: String,
+ val id: String? = null,
+ val more: KitLink? = null,
+ val size: String = "m",
+ val wrap: Boolean = false,
+ val label: String? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "rail"
+
+ /** The cards the rail holds. */
+ val cards: List<Card> get() = children.filterIsInstance<Card>()
+ }
+
+ /** A row: one item in a list. */
+ data class Row(
+ val title: String,
+ val href: String? = null,
+ val rel: String? = null,
+ val meta: String? = null,
+ val sub: String? = null,
+ val mark: String? = null,
+ val unread: Int? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "row"
+ }
+
+ /** A list: rows in one rounded panel. */
+ data class ListPanel(
+ val title: String? = null,
+ val more: KitLink? = null,
+ val empty: String? = null,
+ val flush: Boolean = false,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "list"
+
+ /** The rows the list holds. */
+ val rows: List<Row> get() = children.filterIsInstance<Row>()
+ }
+
+ /** A section: a titled group of anything. */
+ data class Section(
+ val title: String,
+ val id: String? = null,
+ val count: Int? = null,
+ val note: String? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "section"
+ }
+
+ /** A fold: a group that opens and closes. */
+ data class Fold(
+ val title: String,
+ val id: String? = null,
+ val meta: String? = null,
+ val open: Boolean = false,
+ val tone: String? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "fold"
+ }
+
+ /** Tabs: the sections of a screen, one at a time. */
+ data class Tabs(
+ val items: List<KitTab> = emptyList(),
+ val base: String? = null,
+ val active: String? = null,
+ val param: String = "tab",
+ val label: String = "Sections",
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "tabs"
+
+ /** The tab that is open: the one the document names, or the first. */
+ val selectedIndex: Int
+ get() {
+ val i = items.indexOfFirst { it.key == active }
+ return if (i >= 0) i else 0
+ }
+ }
+
+ /** Sidebar: an app's navigation, as rows (on a phone the app shows it in its drawer). */
+ data class Sidebar(
+ val label: String = "Navigation",
+ val nav: List<KitNav> = emptyList(),
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "sidebar"
+ }
+
+ /** Stats: the numbers, in a row. */
+ data class Stats(
+ val items: List<KitStat> = emptyList(),
+ val label: String = "In numbers",
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "stats"
+ }
+
+ /** A notice: one line that says how things stand. */
+ data class Notice(
+ val text: String,
+ val kind: OvNoticeKind = OvNoticeKind.Info,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "notice"
+ }
+
+ /** The Share button: the platform's own share sheet for the link (Android's chooser, with Direct Share). */
+ data class Share(
+ val title: String,
+ val url: String? = null,
+ val label: String = "Share",
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "share"
+ }
+
+ /** An empty state: what a screen shows when it has nothing to show. */
+ data class Empty(
+ val title: String,
+ val text: String? = null,
+ val icon: String? = null,
+ val action: KitLink? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "empty"
+ }
+
+ companion object {
+ /** A document, as JSON, into its nodes. Throws when a node is not a component the kit knows. */
+ fun parse(json: String): KitNode = parse(JSONObject(json))
+
+ /** A node, as JSON, into its component. Throws when the type is not one the kit knows. */
+ fun parse(obj: JSONObject): KitNode {
+ val type = obj.optString("type", "")
+ val props = obj.optJSONObject("props") ?: JSONObject()
+ val kids = childrenOf(obj.optJSONArray("children"))
+ return when (type) {
+ "button" -> Button(
+ label = props.str("label") ?: "",
+ href = props.str("href"),
+ icon = props.str("icon"),
+ kind = when (props.str("kind")) {
+ "quiet" -> OvKind.Quiet
+ "danger" -> OvKind.Danger
+ else -> OvKind.Primary
+ },
+ small = props.bool("small"),
+ )
+ "card" -> Card(
+ href = props.str("href") ?: "",
+ title = props.str("title") ?: "",
+ variant = props.str("type"),
+ ratio = ratioOf(props.str("ratio")),
+ image = props.str("image"),
+ alt = props.str("alt"),
+ badge = props.str("badge"),
+ live = props.bool("live"),
+ corner = props.str("corner"),
+ sub = props.str("sub"),
+ preview = props.str("preview"),
+ who = props.str("who"),
+ avatar = props.str("avatar"),
+ count = props.count("count"),
+ shownAt = props.str("when"),
+ level = props.int("level"),
+ )
+ "rail" -> Rail(
+ title = props.str("title") ?: "",
+ id = props.str("id"),
+ more = props.link("more"),
+ size = props.str("size") ?: "m",
+ wrap = props.bool("wrap"),
+ label = props.str("label"),
+ children = kids,
+ )
+ "row" -> Row(
+ title = props.str("title") ?: "",
+ href = props.str("href"),
+ rel = props.str("rel"),
+ meta = props.str("meta"),
+ sub = props.str("sub"),
+ mark = props.str("mark"),
+ unread = props.int("unread"),
+ )
+ "list" -> ListPanel(
+ title = props.str("title"),
+ more = props.link("more"),
+ empty = props.str("empty"),
+ flush = props.bool("flush"),
+ children = kids,
+ )
+ "section" -> Section(
+ title = props.str("title") ?: "",
+ id = props.str("id"),
+ count = props.int("count"),
+ note = props.str("note"),
+ children = kids,
+ )
+ "fold" -> Fold(
+ title = props.str("title") ?: "",
+ id = props.str("id"),
+ meta = props.str("meta"),
+ open = props.bool("open"),
+ tone = props.str("tone"),
+ children = kids,
+ )
+ "tabs" -> Tabs(
+ items = tabsOf(props.array("items")),
+ base = props.str("base"),
+ active = props.str("active"),
+ param = props.str("param") ?: "tab",
+ label = props.str("label") ?: "Sections",
+ )
+ "sidebar" -> Sidebar(
+ label = props.str("label") ?: "Navigation",
+ nav = navsOf(props.array("nav")),
+ )
+ "stats" -> Stats(
+ items = statsOf(props.array("items")),
+ label = props.str("label") ?: "In numbers",
+ )
+ "notice" -> Notice(
+ text = props.str("text") ?: "",
+ kind = when (props.str("kind")) {
+ "ok" -> OvNoticeKind.Ok
+ "warn" -> OvNoticeKind.Warn
+ "error" -> OvNoticeKind.Error
+ else -> OvNoticeKind.Info
+ },
+ )
+ "share" -> Share(
+ title = props.str("title") ?: "",
+ url = props.str("url"),
+ label = props.str("label") ?: "Share",
+ )
+ "empty" -> Empty(
+ title = props.str("title") ?: "",
+ text = props.str("text"),
+ icon = props.str("icon"),
+ action = props.link("action"),
+ )
+ else -> throw IllegalArgumentException("unknown kit component: " + type)
+ }
+ }
+
+ /** The children of a node, parsed. */
+ private fun childrenOf(array: JSONArray?): List<KitNode> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitNode>()
+ for (i in 0 until array.length()) {
+ val child = array.optJSONObject(i) ?: continue
+ out.add(parse(child))
+ }
+ return out
+ }
+
+ /** The tabs of a tab row. */
+ private fun tabsOf(array: JSONArray?): List<KitTab> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitTab>()
+ for (i in 0 until array.length()) {
+ val tab = array.optJSONObject(i) ?: continue
+ val key = tab.str("key") ?: continue
+ out.add(KitTab(key, tab.str("label") ?: key, tab.int("count")))
+ }
+ return out
+ }
+
+ /** The rows of a sidebar's navigation. */
+ private fun navsOf(array: JSONArray?): List<KitNav> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitNav>()
+ for (i in 0 until array.length()) {
+ val row = array.optJSONObject(i) ?: continue
+ val href = row.str("href") ?: continue
+ out.add(KitNav(href, row.str("label") ?: href, row.str("icon"), row.bool("here"), row.int("count")))
+ }
+ return out
+ }
+
+ /** The numbers of a stats row. */
+ private fun statsOf(array: JSONArray?): List<KitStat> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitStat>()
+ for (i in 0 until array.length()) {
+ val row = array.optJSONArray(i) ?: continue
+ val value = row.optString(0, "")
+ val label = row.optString(1, "")
+ val href = if (row.length() > 2 && !row.isNull(2)) row.optString(2, "") else null
+ out.add(KitStat(value, label, href))
+ }
+ return out
+ }
+ }
+}
+
+// ── Reading the props ──
+
+/** A string prop, or nothing when the document does not carry it. */
+private fun JSONObject.str(key: String): String? = if (has(key) && !isNull(key)) optString(key, "") else null
+
+/** A boolean prop. */
+private fun JSONObject.bool(key: String, def: Boolean = false): Boolean =
+ if (has(key) && !isNull(key)) optBoolean(key, def) else def
+
+/** A number prop. */
+private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
+
+/** An array prop. */
+private fun JSONObject.array(key: String): JSONArray? = if (has(key) && !isNull(key)) optJSONArray(key) else null
+
+/** A link prop: { href, label }. */
+private fun JSONObject.link(key: String): KitLink? {
+ val obj = optJSONObject(key) ?: return null
+ val href = obj.str("href") ?: return null
+ return KitLink(href, obj.str("label") ?: href)
+}
+
+/** A count prop: a number, or [number, word]. */
+private fun JSONObject.count(key: String): String? {
+ val value = opt(key) ?: return null
+ return when (value) {
+ is Number -> value.toString()
+ is String -> value
+ is JSONArray -> {
+ val number = value.opt(0)
+ val word = value.optString(1, "")
+ if (number == null) null else if (word.isEmpty()) number.toString() else number.toString() + " " + word
+ }
+ else -> null
+ }
+}
+
+/** A ratio prop: "16:9" and the like, as the number the view takes. */
+private fun ratioOf(raw: String?): Float? {
+ if (raw == null) return null
+ val parts = raw.split(":")
+ if (parts.size == 2) {
+ val a = parts[0].trim().toFloatOrNull()
+ val b = parts[1].trim().toFloatOrNull()
+ if (a != null && b != null && b != 0f) return a / b
+ }
+ return raw.trim().toFloatOrNull()
+}
+
+// ── Drawing it ──
+
+/**
+ * A kit document, drawn.
+ *
+ * The app wears the theme's Material 3 colour scheme around it (see the kit's preamble), and this draws every
+ * component with the kit's own views. A tap comes back as a KitAction, so the app decides what a link or a button
+ * does.
+ */
+@Composable
+fun KitView(
+ doc: KitNode,
+ icons: Map<String, ImageVector> = emptyMap(),
+ onAction: (KitAction) -> Unit = {},
+) {
+ Column(Modifier.fillMaxWidth()) {
+ KitNodeView(doc, icons, onAction)
+ }
+}
+
+/** One node, drawn with the kit's view for its component. */
+@Composable
+fun KitNodeView(
+ node: KitNode,
+ icons: Map<String, ImageVector> = emptyMap(),
+ onAction: (KitAction) -> Unit = {},
+) {
+ when (node) {
+ is KitNode.Button -> OvButton(
+ label = node.label,
+ onClick = {
+ val href = node.href
+ if (href != null) onAction(KitAction.Open(href)) else onAction(KitAction.Run(node.label))
+ },
+ kind = node.kind,
+ icon = node.icon?.let { icons[it] },
+ )
+ is KitNode.Card -> OvCard(
+ title = node.title,
+ onOpen = { onAction(KitAction.Open(node.href)) },
+ image = node.image,
+ ratio = node.ratioValue,
+ badge = node.badgeText,
+ sub = node.sub,
+ meta = node.metaText,
+ )
+ is KitNode.Rail -> OvRail(
+ title = node.title,
+ seeAll = node.more?.let { more ->
+ val href = more.href
+ { onAction(KitAction.Open(href)) }
+ },
+ ) {
+ items(node.cards) { card ->
+ KitNodeView(card, icons, onAction)
+ }
+ }
+ is KitNode.Row -> {
+ val href = node.href
+ val open: (() -> Unit)? = if (href != null) {
+ val link = href
+ { onAction(KitAction.Open(link)) }
+ } else {
+ null
+ }
+ val markName = node.mark
+ val mark: (@Composable () -> Unit)? = if (markName != null) {
+ val name = markName
+ val slot: @Composable () -> Unit = { OvInitials(name) }
+ slot
+ } else {
+ null
+ }
+ val unread = node.unread
+ val trail: (@Composable () -> Unit)? = if (unread != null && unread > 0) {
+ val count = unread
+ val slot: @Composable () -> Unit = {
+ Text(
+ count.toString(),
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ fontSize = OvScale.TypeSizeMeta,
+ )
+ }
+ slot
+ } else {
+ null
+ }
+ OvRow(
+ title = node.title,
+ onOpen = open,
+ sub = node.sub,
+ meta = node.meta,
+ mark = mark,
+ trail = trail,
+ )
+ }
+ is KitNode.ListPanel -> OvList(title = node.title) {
+ val rows = node.rows
+ if (rows.isEmpty()) {
+ val empty = node.empty
+ if (empty != null) OvEmpty(empty)
+ } else {
+ rows.forEach { row ->
+ KitNodeView(row, icons, onAction)
+ }
+ }
+ }
+ is KitNode.Section -> OvSection(title = node.title, note = node.note) {
+ node.children.forEach { child ->
+ KitNodeView(child, icons, onAction)
+ }
+ }
+ is KitNode.Fold -> OvFold(title = node.title, meta = node.meta, initiallyOpen = node.open) {
+ node.children.forEach { child ->
+ KitNodeView(child, icons, onAction)
+ }
+ }
+ is KitNode.Tabs -> {
+ val labels = node.items.map { it.label }
+ var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }
+ OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })
+ }
+ is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)
+ is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })
+ is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)
+ is KitNode.Share -> OvShare(title = node.title, url = node.url ?: "", label = node.label, onShare = { t, u -> onAction(KitAction.Share(t, u)) })
+ is KitNode.Empty -> {
+ val link = node.action
+ val action: Pair<String, () -> Unit>? = if (link != null) {
+ val href = link.href
+ val label = link.label
+ label to { onAction(KitAction.Open(href)) }
+ } else {
+ null
+ }
+ OvEmpty(title = node.title, text = node.text, action = action)
+ }
+ }
+}
+
+/**
+ * A row's mark: up to two letters on the theme's accent, when the document gives a name and no picture.
+ */
+@Composable
+private fun OvInitials(name: String) {
+ val letters = name.trim().take(2).uppercase()
+ Surface(
+ shape = RoundedCornerShape(OvScale.RadiusPill),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Text(
+ letters,
+ Modifier.padding(horizontal = OvScale.SpaceSm, vertical = OvScale.SpaceXs),
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontSize = OvScale.TypeSizeCaption,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+}
+
+/** A sidebar's navigation, drawn as a drawer's rows: the one you are on is selected, and a tap opens its link. */
+@Composable
+fun OvSidebarNav(node: KitNode.Sidebar, icons: Map<String, ImageVector>, onAction: (KitAction) -> Unit) {
+ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceMd)) {
+ node.nav.forEach { item ->
+ val vector = item.icon?.let { icons[it] }
+ val count = item.count
+ NavigationDrawerItem(
+ label = { Text(item.label) },
+ selected = item.here,
+ onClick = { onAction(KitAction.Open(item.href)) },
+ icon = if (vector != null) ({ Icon(vector, contentDescription = null) }) else null,
+ badge = if (count != null && count > 0) ({ Text(count.toString()) }) else null,
+ shape = RoundedCornerShape(OvScale.RadiusMd),
+ )
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -0,0 +1,248 @@
+// The OpenVibe kit for Jetpack Compose: the reference views of components.js, with the imports they need.
+// Generated by scripts/native-check.js — do not edit; fix components.js instead.
+package network.openvibe.kit
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DrawerState
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.ScrollableTabRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+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.draw.clip
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import network.openvibe.theme.OvScale
+
+// OpenVibe kit for Jetpack Compose: add the theme file from openvibe.design/tokens (Android) and wrap the app in
+// MaterialTheme(colorScheme = if (isSystemInDarkTheme()) VibeDarkColorScheme else VibeLightColorScheme) { … }
+enum class OvKind { Primary, Quiet, Danger }
+enum class OvNoticeKind { Ok, Info, Warn, Error }
+
+// Button (button)
+@Composable
+fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary, icon: ImageVector? = null) {
+ val c = MaterialTheme.colorScheme
+ val colors = when (kind) {
+ OvKind.Primary -> ButtonDefaults.buttonColors(containerColor = c.primaryContainer, contentColor = c.onPrimaryContainer)
+ OvKind.Danger -> ButtonDefaults.buttonColors(containerColor = c.error, contentColor = c.onError)
+ OvKind.Quiet -> ButtonDefaults.outlinedButtonColors(contentColor = c.onSurface)
+ }
+ val content: @Composable RowScope.() -> Unit = {
+ if (icon != null) { Icon(icon, contentDescription = null, Modifier.size(OvScale.SizeIcon)); Spacer(Modifier.width(OvScale.SpaceSm)) }
+ Text(label, fontWeight = FontWeight.Bold)
+ }
+ val m = Modifier.heightIn(min = OvScale.SizeTap)
+ if (kind == OvKind.Quiet) OutlinedButton(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, border = BorderStroke(1.dp, c.outline), content = content)
+ else Button(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, content = content)
+}
+
+// Card (card)
+@Composable
+fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Float = 16f / 9f, badge: String? = null, sub: String? = null, meta: String? = null) {
+ val c = MaterialTheme.colorScheme
+ Column(Modifier.clickable(onClickLabel = title, onClick = onOpen).semantics(mergeDescendants = true) { heading() }) {
+ Box(Modifier.fillMaxWidth().aspectRatio(ratio).clip(RoundedCornerShape(OvScale.RadiusMd)).background(c.surfaceVariant)) {
+ if (image != null) AsyncImage(image, contentDescription = null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
+ if (badge != null) Text(badge, Modifier.padding(OvScale.SpaceSm).background(Color(0xBD000000), RoundedCornerShape(6.dp)).padding(horizontal = 8.dp), color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp)
+ }
+ Spacer(Modifier.height(OvScale.SpaceMd))
+ Text(title, color = c.onSurface, fontWeight = FontWeight.Bold, fontSize = OvScale.TypeSizeBody, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ if (sub != null) Text(sub, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1)
+ if (meta != null) Text(meta, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1)
+ }
+}
+
+// Rail (rail)
+@Composable
+fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.() -> Unit) {
+ Column(Modifier.semantics { heading() }) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = OvScale.SpaceLg), verticalAlignment = Alignment.CenterVertically) {
+ Text(title, Modifier.weight(1f), fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeLead)
+ if (seeAll != null) TextButton(seeAll, Modifier.heightIn(min = OvScale.SizeTap)) { Text("See all") }
+ }
+ val state = rememberLazyListState()
+ LazyRow(state = state, flingBehavior = rememberSnapFlingBehavior(state), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg),
+ horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceMd), content = content)
+ }
+}
+
+// Row (row)
+@Composable
+fun OvRow(title: String, onOpen: (() -> Unit)? = null, sub: String? = null, meta: String? = null, mark: (@Composable () -> Unit)? = null, trail: (@Composable () -> Unit)? = null) {
+ ListItem(
+ headlineContent = { Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ supportingContent = sub?.let { { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = OvScale.TypeSizeMeta) } },
+ leadingContent = mark, trailingContent = trail ?: meta?.let { { Text(it, fontSize = OvScale.TypeSizeMeta) } },
+ modifier = (if (onOpen != null) Modifier.clickable(onClick = onOpen) else Modifier).heightIn(min = 56.dp),
+ )
+}
+
+// List (list)
+@Composable
+fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) {
+ Column {
+ if (title != null) Text(title.uppercase(), Modifier.padding(OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), tonalElevation = 1.dp) { Column(content = content) }
+ }
+}
+
+// Section (section)
+@Composable
+fun OvSection(title: String, note: String? = null, content: @Composable ColumnScope.() -> Unit) {
+ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
+ Column(Modifier.padding(vertical = OvScale.SpaceMd)) {
+ Text(title.uppercase(), Modifier.padding(horizontal = OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (note != null) Text(note, Modifier.padding(horizontal = OvScale.SpaceMd), fontSize = OvScale.TypeSizeMeta, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ content()
+ }
+ }
+}
+
+// Fold (fold)
+@Composable
+fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false, content: @Composable () -> Unit) {
+ var open by rememberSaveable { mutableStateOf(initiallyOpen) }
+ Surface(shape = RoundedCornerShape(OvScale.RadiusMd)) {
+ Column {
+ Row(Modifier.fillMaxWidth().heightIn(min = OvScale.SizeTap).clickable(onClickLabel = if (open) "Close" else "Open") { open = !open }.padding(horizontal = OvScale.SpaceMd),
+ verticalAlignment = Alignment.CenterVertically) {
+ Text(title, Modifier.weight(1f), fontWeight = FontWeight.Bold)
+ if (meta != null) Text(meta, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(Icons.Default.ExpandMore, null, Modifier.rotate(if (open) 180f else 0f))
+ }
+ AnimatedVisibility(open) { Box(Modifier.padding(OvScale.SpaceMd)) { content() } }
+ }
+ }
+}
+
+// Tabs (tabs)
+@Composable
+fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) {
+ ScrollableTabRow(selectedTabIndex = selected, edgePadding = OvScale.SpaceLg) {
+ tabs.forEachIndexed { i, t -> Tab(selected = i == selected, onClick = { onSelect(i) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), text = { Text(t) }) }
+ }
+}
+
+// Sidebar (sidebar)
+@Composable
+fun OvSidebar(items: List<Pair<String, ImageVector>>, current: Int, onPick: (Int) -> Unit, drawer: DrawerState, content: @Composable () -> Unit) {
+ ModalNavigationDrawer(drawerState = drawer, drawerContent = {
+ ModalDrawerSheet(Modifier.widthIn(max = 320.dp)) {
+ Column(Modifier.padding(OvScale.SpaceMd)) {
+ items.forEachIndexed { i, (label, icon) ->
+ NavigationDrawerItem(label = { Text(label) }, icon = { Icon(icon, null, Modifier.size(OvScale.SizeIcon)) }, selected = i == current,
+ onClick = { onPick(i) }, shape = RoundedCornerShape(OvScale.RadiusMd), modifier = Modifier.heightIn(min = OvScale.SizeTap))
+ }
+ }
+ }
+ }, content = content)
+}
+
+// Numbers (stats)
+@Composable
+fun OvStats(items: List<Pair<String, String>>) {
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg)) {
+ items(items) { (value, label) ->
+ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
+ Column(Modifier.widthIn(min = 104.dp).padding(horizontal = 14.dp, vertical = 8.dp).semantics(mergeDescendants = true) {}) {
+ Text(value, fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeTitle, style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"))
+ Text(label, fontSize = OvScale.TypeSizeCaption, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+ }
+}
+
+// Notice (notice)
+@Composable
+fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) {
+ val c = MaterialTheme.colorScheme
+ val tint = when (kind) { OvNoticeKind.Error -> c.error; OvNoticeKind.Ok -> c.primary; else -> c.tertiary }
+ Row(Modifier.fillMaxWidth().semantics { liveRegion = if (kind == OvNoticeKind.Error) LiveRegionMode.Assertive else LiveRegionMode.Polite }
+ .background(tint.copy(alpha = .12f), RoundedCornerShape(OvScale.RadiusSm)).padding(OvScale.SpaceMd), horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) {
+ Icon(if (kind == OvNoticeKind.Error) Icons.Default.Error else Icons.Default.Info, null, tint = tint)
+ Text(text, color = c.onSurface)
+ }
+}
+
+// Share (share)
+@Composable
+fun OvShare(title: String, url: String, label: String = "Share", onShare: (title: String, url: String) -> Unit) {
+ // The app hands the link to the platform's own sheet, with Direct Share's contacts. On Android:
+ // context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, url) }, title))
+ OutlinedButton(onClick = { onShare(title, url) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), shape = RoundedCornerShape(OvScale.RadiusPill)) {
+ Icon(Icons.Default.Share, contentDescription = null)
+ Spacer(Modifier.width(OvScale.SpaceSm))
+ Text(label, fontWeight = FontWeight.Bold)
+ }
+}
+
+// Empty state (empty)
+@Composable
+fun OvEmpty(title: String, text: String? = null, action: Pair<String, () -> Unit>? = null) {
+ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceXl), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) {
+ Text(title, fontWeight = FontWeight.Bold)
+ if (text != null) Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ if (action != null) OvButton(action.first, action.second)
+ }
+}
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+// The check's stand-ins: what an app brings with it, not what the Compose artifacts carry.
+// The kit's card view draws a picture through an image loader (Coil, Glide); the CI does not pull one in, it only
+// needs the call to type-check.
+package network.openvibe.kit
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+
+@Composable
+fun AsyncImage(model: Any?, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) {
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+package network.openvibe.link
+
+import android.app.Application
+import network.openvibe.link.data.Store
+import network.openvibe.link.service.ShareUploadService
+import network.openvibe.link.service.StayConnectedService
+
+class LinkApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ Store.init(this)
+ StayConnectedService.ensureChannel(this)
+ ShareUploadService.ensureChannel(this)
+ if (Store.paired != null && Store.stayConnected) {
+ try {
+ StayConnectedService.start(this)
+ } catch (e: Exception) {
+ // Android may refuse a background start; the switch starts it the next time the app is opened.
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+package network.openvibe.link
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+import androidx.activity.compose.setContent
+import network.openvibe.link.data.Api
+import network.openvibe.link.data.LinkController
+import network.openvibe.link.data.PairLinks
+import network.openvibe.link.data.Store
+import network.openvibe.link.ui.Incoming
+import network.openvibe.link.ui.LinkApp
+import network.openvibe.link.ui.LinkTheme
+
+/**
+ * The app's one activity. It answers the pairing link (`openvibe-link://pair?code=…`) and the sign-in's return
+ * (`https://openvibe.network/link/auth?code=…`, the claimed App Link); both arrive as intents, and the Compose tree
+ * reads them through [Incoming]. A pair link naming another server is refused here, before anything pairs.
+ */
+class MainActivity : FragmentActivity() {
+ private val askNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Store.init(applicationContext)
+ read(intent)
+ // Without "Stay connected" the link lives only while the app is on screen (walked back from in onStop).
+ if (Store.paired != null && !Store.stayConnected) LinkController.start()
+ setContent { LinkTheme { LinkApp() } }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ override fun onStop() {
+ if (!Store.stayConnected) LinkController.stop()
+ super.onStop()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ read(intent)
+ }
+
+ private fun read(intent: Intent?) {
+ val data = intent?.data ?: return
+ if (data.host == PairLinks.PAIR_HOST) {
+ val link = PairLinks.parse(data.toString())
+ Incoming.pairRefused = link?.refusedServer
+ Incoming.pairCode = if (link?.refusedServer != null) null else link?.code ?: data.getQueryParameter("code")
+ return
+ }
+ val ours = data.scheme == "https" && data.host == Uri.parse(Api.NETWORK).host && data.path == PairLinks.AUTH_PATH
+ if (ours) Incoming.authUri = data.toString()
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+package network.openvibe.link
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import network.openvibe.link.data.Session
+import network.openvibe.link.data.Store
+import network.openvibe.link.service.ShareUploadService
+import network.openvibe.link.share.ConfirmShare
+import network.openvibe.link.share.ShareIntents
+import network.openvibe.link.share.ShareRequest
+import network.openvibe.link.share.ShareUploader
+import network.openvibe.link.ui.LinkTheme
+
+/**
+ * "Send to OpenVibe" from any app's share sheet. Nothing is sent on the intent alone: the sheet asks first, and only
+ * "Send" starts the upload (the drive's own, the same one the web page uses) in a foreground service, with progress
+ * in a notification. Cancel closes the sheet and the source app comes back with the file untouched.
+ */
+class ShareActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Store.init(applicationContext)
+
+ val intent = intent
+ val uris = mutableListOf<String>()
+ val clip = intent.clipData
+ if (clip != null && clip.itemCount > 0) {
+ for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let { uris.add(it.toString()) }
+ } else {
+ @Suppress("DEPRECATION")
+ (intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it.toString()) }
+ }
+ val request = ShareIntents.parse(
+ intent.action,
+ intent.type,
+ uris,
+ intent.getStringExtra(Intent.EXTRA_TEXT),
+ )
+ if (request == null) {
+ Toast.makeText(this, "There is nothing to send.", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+ if (Store.paired == null) {
+ Toast.makeText(this, "Pair this phone with OpenVibe first.", Toast.LENGTH_LONG).show()
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ return
+ }
+ if (!Session.hasSession()) {
+ Toast.makeText(this, "Sign in to OpenVibe to send files.", Toast.LENGTH_LONG).show()
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ return
+ }
+
+ setContent {
+ LinkTheme {
+ ConfirmShare(
+ request = request,
+ onSend = { send(request); finish() },
+ onCancel = { finish() },
+ )
+ }
+ }
+ }
+
+ private fun send(request: ShareRequest) {
+ val files = request.uris.toMutableList()
+ val names = mutableListOf<String>()
+ if (files.isEmpty() && request.text != null) {
+ val (uri, name) = ShareUploader.textFile(this, request.text)
+ files.add(uri.toString())
+ names.add(name)
+ }
+ ShareUploadService.start(this, files, names, request.mimeType)
+ Toast.makeText(this, if (files.size > 1) "Sending ${files.size} files…" else "Sending to OpenVibe…", Toast.LENGTH_SHORT).show()
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+package network.openvibe.link.approval
+
+/**
+ * What an approval is about. Four of these are consequential enough that the phone asks for a fingerprint, a face or
+ * the device credential before it sends an answer: money, passwords, deletions and a send to someone new (plan
+ * 2026-11-07 §13.2: "Money, passwords, deletions and sends need a fingerprint or face confirmation").
+ *
+ * The category is the item's own `category` when the service sends one; otherwise it is read from the approval's
+ * kind, title and summary. Actor's approvals today are all `codes.task`, whose title is the person's own sentence, so
+ * the words are the only signal a phone has — hence the tests beside this file.
+ */
+enum class ApprovalCategory(val wire: String, val label: String, val needsBiometrics: Boolean) {
+ MONEY("money", "Money", true),
+ PASSWORD("password", "Passwords and codes", true),
+ DELETE("delete", "Deleting", true),
+ NEW_RECIPIENT("new_recipient", "A new recipient", true),
+ OTHER("other", "Other", false);
+
+ companion object {
+ /** The service's own category, when it names one this app knows. */
+ fun fromWire(value: String?): ApprovalCategory? =
+ entries.firstOrNull { it.wire == value?.trim()?.lowercase() }
+ }
+}
+
+object ApprovalRules {
+ // Whole words only: "display" is not a payment and "monetary" is.
+ private val MONEY = Regex(
+ "\\b(money|payment|pay|paid|price|cost|charge|invoice|refund|purchase|buy|transfer|tip|deposit|withdraw|" +
+ "subscription|billing|checkout|stripe|paypal|card|usd|eur|gbp)\\b|[$€£¥]|\\d+[.,]?\\d*\\s?(usd|eur|gbp)",
+ RegexOption.IGNORE_CASE,
+ )
+ private val PASSWORD = Regex(
+ "\\b(password|passcode|passphrase|secret|credential|credentials|api[ _-]?key|token|2fa|otp|one[- ]time " +
+ "code|authenticator|login|sign[- ]in|private key|seed phrase)\\b",
+ RegexOption.IGNORE_CASE,
+ )
+ private val DELETE = Regex(
+ "\\b(delete|deletes|deleting|deleted|remove|removes|removing|erase|erasing|wipe|wiping|destroy|purge|" +
+ "cancel (my )?(plan|account|subscription)|close (my )?account|unlink|drop)\\b",
+ RegexOption.IGNORE_CASE,
+ )
+ private val NEW_RECIPIENT = Regex(
+ "\\b(new (recipient|contact|person|address|number|email|phone)|" +
+ "(send|share|email|mail|text|message|forward|invite|introduce)\\b[^.]{0,60}\\bto\\b|" +
+ "reply to|invite)\\b",
+ RegexOption.IGNORE_CASE,
+ )
+
+ /**
+ * The category of one approval. An explicit category the service sent wins; otherwise the most consequential
+ * reading of the words wins, so a sentence that both sends and pays is money, and a deletion is never shown as
+ * something smaller.
+ */
+ fun classify(explicit: String?, kind: String?, title: String?, summary: String?): ApprovalCategory {
+ ApprovalCategory.fromWire(explicit)?.let { return it }
+ val text = listOfNotNull(kind, title, summary).joinToString(" ")
+ if (text.isBlank()) return ApprovalCategory.OTHER
+ return when {
+ MONEY.containsMatchIn(text) -> ApprovalCategory.MONEY
+ PASSWORD.containsMatchIn(text) -> ApprovalCategory.PASSWORD
+ DELETE.containsMatchIn(text) -> ApprovalCategory.DELETE
+ NEW_RECIPIENT.containsMatchIn(text) -> ApprovalCategory.NEW_RECIPIENT
+ else -> ApprovalCategory.OTHER
+ }
+ }
+
+ /** True when answering this one needs a fingerprint, a face or the device credential first. */
+ fun needsBiometrics(explicit: String?, kind: String?, title: String?, summary: String?): Boolean =
+ classify(explicit, kind, title, summary).needsBiometrics
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+package network.openvibe.link.approval
+
+import android.content.Context
+import android.os.Build
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+
+/**
+ * The lock before an answer that matters. BIOMETRIC_STRONG (a fingerprint or a face the phone trusts) with the device
+ * credential behind it on Android 11+, and a fingerprint or the device credential on 8–10; the answer is only sent
+ * from the success callback.
+ */
+object Biometrics {
+ fun available(context: Context): Boolean {
+ val manager = BiometricManager.from(context)
+ val allowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
+ } else {
+ BiometricManager.Authenticators.BIOMETRIC_STRONG
+ }
+ return manager.canAuthenticate(allowed) == BiometricManager.BIOMETRIC_SUCCESS
+ }
+
+ fun confirm(
+ activity: FragmentActivity,
+ title: String,
+ onSuccess: () -> Unit,
+ onError: (String) -> Unit,
+ ) {
+ val prompt = BiometricPrompt(
+ activity,
+ ContextCompat.getMainExecutor(activity),
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = onSuccess()
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_CANCELED) return
+ onError(errString.toString())
+ }
+ override fun onAuthenticationFailed() {
+ // One wrong finger: the prompt stays up and asks again.
+ }
+ },
+ )
+ val info = BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Confirm it's you")
+ .setSubtitle(title)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ info.setAllowedAuthenticators(
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL,
+ )
+ } else {
+ info.setNegativeButtonText("Cancel")
+ info.setDeviceCredentialAllowed(true)
+ }
+ prompt.authenticate(info.build())
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+package network.openvibe.link.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.util.concurrent.TimeUnit
+
+/** One item of the Action Center (`common.action-item@1`), which is what the Dock's activity tab draws. */
+@Serializable
+data class ActionItem(
+ val service: String = "",
+ val id: String = "",
+ val kind: String = "",
+ val title: String = "",
+ val summary: String? = null,
+ val bucket: String = "waiting",
+ val state: String? = null,
+ val agent: String? = null,
+ @SerialName("created_at") val createdAt: String? = null,
+ @SerialName("updated_at") val updatedAt: String? = null,
+ @SerialName("next_run_at") val nextRunAt: String? = null,
+ val error: String? = null,
+ val href: String? = null,
+ val actions: List<String> = emptyList(),
+ /** Actor's own category, when it sends one (money, password, delete, new_recipient). */
+ val category: String? = null,
+) {
+ val isApproval: Boolean get() = kind == "approval"
+ val isPending: Boolean get() = bucket == "waiting" || state == "pending"
+}
+
+/** `network.me-actions-result@1`: the buckets, the counts, and whether a source did not answer. */
+@Serializable
+data class ActionsDoc(
+ val buckets: Map<String, List<ActionItem>> = emptyMap(),
+ val counts: Map<String, Int> = emptyMap(),
+ val partial: Boolean = false,
+)
+
+class ApiException(message: String) : Exception(message)
+
+/**
+ * The person's own reads and answers on OpenVibe.Network, with the session token. Every route is under `/me`, and the
+ * subject comes from the token, so this app can never read or answer another person's work.
+ */
+object Api {
+ /** The server this build signs in and pairs with (Servers): a deep link never chooses it. */
+ val NETWORK: String get() = Servers.network
+ const val BOT = "https://openvibe.bot"
+ const val DOWNLOAD = "https://openvibe.download"
+
+ const val APPROVE = "approve"
+ const val DECLINE = "decline"
+
+ private val json = Json { ignoreUnknownKeys = true }
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private val JSON_TYPE = "application/json; charset=utf-8".toMediaType()
+
+ suspend fun actions(): ActionsDoc = withContext(Dispatchers.IO) {
+ val token = Session.token() ?: throw ApiException("Sign in to OpenVibe to see your approvals.")
+ val request = Request.Builder()
+ .url("$NETWORK/api/v1/me/actions")
+ .header("Authorization", "Bearer $token")
+ .header("Accept", "application/json")
+ .get()
+ .build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (response.code == 401) {
+ Store.clearTokens()
+ throw ApiException("Your session ended. Sign in again.")
+ }
+ if (!response.isSuccessful) throw ApiException("OpenVibe answered ${response.code}.")
+ try {
+ json.decodeFromString<ActionsDoc>(text)
+ } catch (e: Exception) {
+ throw ApiException("OpenVibe's answer could not be read.")
+ }
+ }
+ }
+
+ /** Answer one Actor approval as the person themself (the same decision /me/approvals makes). */
+ suspend fun decide(item: ActionItem, approve: Boolean): ActionItem = withContext(Dispatchers.IO) {
+ if (item.service != "actor" || !item.isApproval) throw ApiException("This item is not decided here.")
+ val token = Session.token() ?: throw ApiException("Sign in to OpenVibe to answer approvals.")
+ val action = if (approve) APPROVE else DECLINE
+ val url = "$NETWORK/api/v1/me/actions/${item.service}/${item.id}/$action"
+ val request = Request.Builder()
+ .url(url)
+ .header("Authorization", "Bearer $token")
+ .header("Accept", "application/json")
+ .post("{}".toRequestBody(JSON_TYPE))
+ .build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (response.code == 401) {
+ Store.clearTokens()
+ throw ApiException("Your session ended. Sign in again.")
+ }
+ if (!response.isSuccessful) throw ApiException(problem(text) ?: "OpenVibe answered ${response.code}.")
+ val body = try {
+ json.parseToJsonElement(text)
+ } catch (e: Exception) {
+ throw ApiException("OpenVibe's answer could not be read.")
+ }
+ val itemJson = (body as? kotlinx.serialization.json.JsonObject)?.get("item") ?: body
+ try {
+ json.decodeFromJsonElement(ActionItem.serializer(), itemJson)
+ } catch (e: Exception) {
+ item.copy(state = action)
+ }
+ }
+ }
+
+ private fun problem(text: String): String? = try {
+ (json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject)
+ ?.get("detail")?.toString()?.trim('"')?.takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ null
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+package network.openvibe.link.data
+
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+/**
+ * The one Go link this app holds, and the state the screens draw. `mobile.Link` is the AAR the Go core is bound into
+ * (mobile/mobile.go): the same pairing and reconnecting WebSocket the Node runs. Kotlin only starts, stops and reads it.
+ */
+object LinkController {
+ /** "stopped", "connected", "offline" or "revoked". */
+ var state: String by mutableStateOf("stopped")
+ private set
+ var detail: String? by mutableStateOf(null)
+ private set
+ var connected: Boolean by mutableStateOf(false)
+ private set
+
+ private val main = Handler(Looper.getMainLooper())
+ private var link: mobile.Link? = null
+
+ @Synchronized
+ fun start() {
+ if (link != null) return
+ val pairing = Store.paired ?: return
+ val started = try {
+ mobile.Mobile.newLink(pairing)
+ } catch (e: Exception) {
+ publish("revoked", "This phone's pairing could not be read. Pair it again.")
+ return
+ }
+ link = started
+ publish("offline", "Connecting…")
+ try {
+ started.start(object : mobile.Listener {
+ override fun onState(event: String) = onGoState(event)
+ })
+ } catch (e: Exception) {
+ publish("offline", "Could not start the connection.")
+ link = null
+ }
+ }
+
+ @Synchronized
+ fun stop() {
+ val current = link ?: return
+ link = null
+ try {
+ current.stop()
+ } catch (e: Exception) {
+ // The Go side is already gone; nothing to close.
+ }
+ publish("stopped", null)
+ }
+
+ /** What "This phone" shows, from the Go core: the device, the account, the kind and whether it is bound. */
+ fun info(): String? {
+ val pairing = Store.paired ?: return null
+ return try {
+ mobile.Mobile.info(pairing)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ private fun onGoState(event: String) {
+ val state = eventState(event)
+ val detail = eventDetail(event)
+ main.post {
+ when (state) {
+ "connected" -> {
+ connected = true
+ this.state = "connected"
+ this.detail = null
+ }
+ "offline" -> {
+ connected = false
+ this.state = "offline"
+ this.detail = detail
+ }
+ "stopped" -> {
+ connected = false
+ this.state = "stopped"
+ this.detail = null
+ }
+ "revoked" -> {
+ connected = false
+ this.state = "revoked"
+ this.detail = detail
+ }
+ }
+ }
+ }
+
+ private fun publish(next: String, message: String?) {
+ main.post {
+ state = next
+ detail = message
+ connected = next == "connected"
+ }
+ }
+
+ private fun eventState(event: String): String = try {
+ org.json.JSONObject(event).optString("state", "offline")
+ } catch (e: Exception) {
+ "offline"
+ }
+
+ private fun eventDetail(event: String): String? = try {
+ org.json.JSONObject(event).optString("detail").takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ null
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+package network.openvibe.link.data
+
+import java.net.URLDecoder
+import java.net.URLEncoder
+
+/**
+ * The two links the app answers: `openvibe-link://pair?code=…` (the QR on openvibe.bot/link/phone) and the sign-in's
+ * return, an https App Link for `https://openvibe.network/link/auth` (MainActivity). Parsed here, away from Android,
+ * so the shapes have tests.
+ *
+ * The pair link names no server, and if it names one it must be this build's (Servers.network): a link that points
+ * elsewhere is refused, never followed, because the phone would otherwise send its pairing to whoever wrote the link.
+ */
+data class PairLink(
+ val code: String?,
+ val network: String?,
+ val pairing: String? = null,
+ /** The server a refused link named (its host when it parses, the raw value otherwise); null when the link is ours. */
+ val refusedServer: String? = null,
+)
+
+object PairLinks {
+ const val PAIR_SCHEME = "openvibe-link"
+ const val PAIR_HOST = "pair"
+ const val AUTH_PATH = "/link/auth"
+
+ fun parse(uri: String?, accepted: String = Servers.network): PairLink? {
+ if (uri.isNullOrBlank()) return null
+ val trimmed = uri.trim()
+ val prefix = "$PAIR_SCHEME://$PAIR_HOST"
+ if (!trimmed.startsWith(prefix, ignoreCase = true)) return null
+ val params = queryParams(trimmed.substringAfter('?', missingDelimiterValue = ""))
+ return link(params, accepted)
+ }
+
+ /** A scanned QR: our link, the code itself, or a page whose `code` query carries it. */
+ fun codeFromScan(contents: String?, accepted: String = Servers.network): PairLink? {
+ val text = contents?.trim().orEmpty()
+ if (text.isEmpty()) return null
+ parse(text, accepted)?.let { if (it.code != null || it.refusedServer != null) return it }
+ val params = queryParams(text.substringAfter('?', missingDelimiterValue = ""))
+ if (params["code"]?.isNotBlank() == true) return link(params, accepted)
+ val bare = text.replace("-", "").replace(" ", "")
+ return if (bare.length == 8 && bare.all { it.isLetterOrDigit() }) PairLink(text, accepted) else null
+ }
+
+ /** The link's code, pairing id and server — refused whole when the server is someone else's. */
+ private fun link(params: Map<String, String>, accepted: String): PairLink {
+ val named = params["network"]?.takeIf { it.isNotBlank() }
+ if (named != null && !Servers.same(named, accepted)) {
+ return PairLink(code = null, network = accepted, refusedServer = Servers.normalize(named)?.let { hostOf(it) } ?: named)
+ }
+ return PairLink(
+ code = params["code"]?.takeIf { it.isNotBlank() },
+ network = accepted,
+ pairing = params["pairing"]?.takeIf { it.startsWith("pair_") },
+ )
+ }
+
+ private fun hostOf(url: String): String {
+ val rest = url.substringAfter("://", missingDelimiterValue = "")
+ return rest.substringBefore('/').substringBefore(':').ifBlank { url }
+ }
+
+ private fun queryParams(query: String): Map<String, String> {
+ if (query.isBlank()) return emptyMap()
+ return query.split('&').mapNotNull { part ->
+ if (part.isBlank()) return@mapNotNull null
+ val key = part.substringBefore('=')
+ val value = part.substringAfter('=', missingDelimiterValue = "")
+ if (key.isBlank()) return@mapNotNull null
+ URLDecoder.decode(key, "UTF-8") to URLDecoder.decode(value, "UTF-8")
+ }.toMap()
+ }
+
+ fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+package network.openvibe.link.data
+
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+
+/**
+ * PKCE (RFC 7636, S256) for the sign-in. The phone is a public OAuth client: it holds no secret, and the verifier it
+ * generates here is the only proof that the code it redeems is its own. Kept away from Android so it has tests.
+ */
+object Pkce {
+ private val random = SecureRandom()
+
+ /** 43 characters of base64url without padding, from 32 random bytes. */
+ fun verifier(): String {
+ val bytes = ByteArray(32)
+ random.nextBytes(bytes)
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
+ }
+
+ fun challenge(verifier: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
+ }
+
+ /** A random `state`, so a code that comes back from another request is refused. */
+ fun state(): String {
+ val bytes = ByteArray(16)
+ random.nextBytes(bytes)
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
+ }
+
+ /** The shape the server accepts (PKCE_RE): 43–128 unreserved characters. */
+ fun isVerifier(value: String?): Boolean =
+ value != null && value.length in 43..128 && value.all { it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == '~' }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+package network.openvibe.link.data
+
+import android.net.Uri
+import network.openvibe.link.BuildConfig
+
+/**
+ * Where this build of the app signs in and pairs. It is a build constant, never the QR's or the person's choice: a code
+ * and a redirect belong to one server, and a link naming another is refused (PairLinks).
+ *
+ * A release build always carries https://openvibe.network. A debug build may be pointed elsewhere when it is built
+ * (`./gradlew :app:assembleDebug -Pnetwork.url=https://…`); that build says so in a banner on every screen, so a
+ * screenshot or a bug report can never be mistaken for production.
+ */
+object Servers {
+ const val OFFICIAL = "https://openvibe.network"
+
+ /** The server this build talks to, with no trailing slash. */
+ val network: String get() = BuildConfig.NETWORK_URL.trimEnd('/')
+
+ /** True in a debug build that was pointed at another server: what the banner shows. */
+ val isDebugOverride: Boolean get() = BuildConfig.DEBUG_SERVER && network != OFFICIAL
+
+ /** The host, for a sentence ("… points at evil.test"). */
+ val host: String get() = Uri.parse(network).host ?: network
+
+ /** Two server URLs are the same when they differ only by case, slashes or an empty path. */
+ fun same(a: String?, b: String?): Boolean =
+ normalize(a) != null && normalize(a) == normalize(b)
+
+ fun normalize(url: String?): String? {
+ val v = url?.trim()?.trimEnd('/') ?: return null
+ return if (v.startsWith("https://", ignoreCase = true)) v.lowercase() else null
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+package network.openvibe.link.data
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.FormBody
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+
+/**
+ * The person's own OpenVibe.Network session, for the Approvals and Activity screens and for sending files to their
+ * Download drive. It is a public OAuth client (`link`): authorization code + PKCE S256, no secret in the APK, and the
+ * tokens are encrypted with the Android Keystore (Store).
+ *
+ * The code comes back on `https://openvibe.network/link/auth`, an Android App Link claimed for this package by
+ * Network's /.well-known/assetlinks.json: a custom scheme could be answered by any app, this one only by us. The
+ * verifier never leaves the phone except in the exchange it belongs to, and no token is ever logged.
+ */
+object Session {
+ private const val CLIENT_ID = "link"
+ const val REDIRECT_URI = "https://openvibe.network/link/auth"
+ private val AUTHORIZE: String get() = "${Api.NETWORK}/oauth/authorize"
+ private val TOKEN: String get() = "${Api.NETWORK}/oauth/token"
+ private const val SCOPE = "profile theme"
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private var verifier: String? = null
+ private var state: String? = null
+
+ /** The verifier this app is waiting to redeem; null when no sign-in is in flight. */
+ val pendingVerifier: String? get() = verifier
+
+ /** Open the account chooser in a Custom Tab. Returns false when no browser answered. */
+ fun signIn(context: Context): Boolean {
+ val v = Pkce.verifier()
+ val s = Pkce.state()
+ verifier = v
+ state = s
+ val url = Uri.parse(AUTHORIZE).buildUpon()
+ .appendQueryParameter("response_type", "code")
+ .appendQueryParameter("client_id", CLIENT_ID)
+ .appendQueryParameter("redirect_uri", REDIRECT_URI)
+ .appendQueryParameter("scope", SCOPE)
+ .appendQueryParameter("state", s)
+ .appendQueryParameter("code_challenge", Pkce.challenge(v))
+ .appendQueryParameter("code_challenge_method", "S256")
+ .build()
+ return try {
+ CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, url)
+ true
+ } catch (e: Exception) {
+ try {
+ context.startActivity(Intent(Intent.ACTION_VIEW, url).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ true
+ } catch (e2: Exception) {
+ false
+ }
+ }
+ }
+
+ /** Redeem the code the browser returned. Throws with what went wrong; the caller shows it. */
+ suspend fun complete(uri: Uri): Unit = withContext(Dispatchers.IO) {
+ val error = uri.getQueryParameter("error")
+ if (error != null) throw IllegalStateException(
+ if (error == "access_denied") "Sign-in was cancelled." else "Sign-in failed: $error",
+ )
+ val code = uri.getQueryParameter("code") ?: throw IllegalStateException("Sign-in returned no code.")
+ val returned = uri.getQueryParameter("state")
+ val expected = state
+ val v = verifier
+ if (expected == null || returned != expected) throw IllegalStateException("Sign-in returned for another request.")
+ if (!Pkce.isVerifier(v)) throw IllegalStateException("Sign-in lost its verifier; try again.")
+
+ val body = FormBody.Builder()
+ .add("grant_type", "authorization_code")
+ .add("client_id", CLIENT_ID)
+ .add("redirect_uri", REDIRECT_URI)
+ .add("code", code)
+ .add("code_verifier", v!!)
+ .build()
+ val request = Request.Builder().url(TOKEN).post(body).header("Accept", "application/json").build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (!response.isSuccessful) throw IllegalStateException(problem(text, response.code))
+ val json = JSONObject(text)
+ val access = json.optString("access_token")
+ if (access.isEmpty()) throw IllegalStateException("Sign-in returned no token.")
+ val expiresIn = json.optLong("expires_in", 3600L)
+ Store.saveTokens(access, json.optString("refresh_token").takeIf { it.isNotEmpty() }, System.currentTimeMillis() + expiresIn * 1000)
+ }
+ verifier = null
+ state = null
+ }
+
+ /** A usable access token, refreshed when it is close to its expiry. Null means sign in again. */
+ suspend fun token(): String? = withContext(Dispatchers.IO) {
+ val access = Store.accessToken()
+ val expires = Store.expiresAt()
+ if (access != null && System.currentTimeMillis() < expires - 60_000) return@withContext access
+ val refresh = Store.refreshToken() ?: return@withContext access.takeIf { System.currentTimeMillis() < expires }
+ val body = FormBody.Builder()
+ .add("grant_type", "refresh_token")
+ .add("client_id", CLIENT_ID)
+ .add("refresh_token", refresh)
+ .build()
+ val request = Request.Builder().url(TOKEN).post(body).header("Accept", "application/json").build()
+ try {
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ if (response.code == 400 || response.code == 401) Store.clearTokens()
+ return@withContext null
+ }
+ val json = JSONObject(text)
+ val next = json.optString("access_token").takeIf { it.isNotEmpty() } ?: return@withContext null
+ val expiresIn = json.optLong("expires_in", 3600L)
+ Store.saveTokens(next, json.optString("refresh_token").takeIf { it.isNotEmpty() } ?: refresh, System.currentTimeMillis() + expiresIn * 1000)
+ next
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun signOut() {
+ verifier = null
+ state = null
+ Store.clearTokens()
+ }
+
+ /** A token is held (it may still need a refresh; [token] does that). */
+ fun hasSession(): Boolean = Store.accessToken() != null
+
+ private fun problem(text: String, status: Int): String {
+ val detail = try {
+ JSONObject(text).optString("error_description").takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ null
+ }
+ return detail ?: "Sign-in was refused ($status)."
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+package network.openvibe.link.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * Everything secret the app keeps, encrypted with a key the Android Keystore makes and never exports: the pairing's
+ * node credential, the session's access and refresh tokens. Losing the key (a cleared Keystore, a restore onto
+ * another phone) loses the secrets: the person pairs again, which is the right answer. Nothing secret is ever written
+ * to a log or to a crash report, and this app's data is excluded from Android backups.
+ */
+object Store {
+ private const val PREFS = "openvibe-link"
+ private const val KEY_ALIAS = "openvibe-link-secrets"
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
+ private const val GCM_TAG_BITS = 128
+
+ private lateinit var prefs: SharedPreferences
+
+ /** The pairing JSON the Go core reads; null when this phone is not paired. Compose observes it. */
+ var paired: String? by mutableStateOf(null)
+ private set
+
+ /** True when a Network session (the person's own token) is held. */
+ var signedIn: Boolean by mutableStateOf(false)
+ private set
+
+ /** The "Stay connected" switch, remembered across launches. */
+ var stayConnected: Boolean by mutableStateOf(false)
+ private set
+
+ var deviceLabel: String by mutableStateOf("")
+ private set
+
+ fun init(context: Context) {
+ prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ stayConnected = prefs.getBoolean("stay_connected", false)
+ deviceLabel = prefs.getString("device_label", "").orEmpty()
+ paired = read("pairing")
+ signedIn = !read("access").isNullOrEmpty()
+ }
+
+ @Synchronized
+ fun savePairing(json: String, label: String) {
+ write("pairing", json)
+ deviceLabel = label
+ prefs.edit().putString("device_label", label).apply()
+ paired = json
+ }
+
+ @Synchronized
+ fun forgetPairing() {
+ prefs.edit().remove(encryptedKey("pairing")).remove("device_label").apply()
+ paired = null
+ deviceLabel = ""
+ }
+
+ @Synchronized
+ fun saveTokens(access: String, refresh: String?, expiresAt: Long) {
+ write("access", access)
+ refresh?.let { write("refresh", it) }
+ prefs.edit().putLong("expires_at", expiresAt).apply()
+ signedIn = true
+ }
+
+ @Synchronized
+ fun clearTokens() {
+ prefs.edit().remove(encryptedKey("access")).remove(encryptedKey("refresh")).remove("expires_at").apply()
+ signedIn = false
+ }
+
+ fun accessToken(): String? = read("access")
+
+ fun refreshToken(): String? = read("refresh")
+
+ fun expiresAt(): Long = prefs.getLong("expires_at", 0L)
+
+ fun keepConnected(on: Boolean) {
+ stayConnected = on
+ prefs.edit().putBoolean("stay_connected", on).apply()
+ }
+
+ private fun encryptedKey(name: String) = "enc_$name"
+
+ private fun write(name: String, value: String) {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, key())
+ val sealed = cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
+ prefs.edit().putString(encryptedKey(name), Base64.encodeToString(sealed, Base64.NO_WRAP)).apply()
+ }
+
+ private fun read(name: String): String? {
+ val stored = prefs.getString(encryptedKey(name), null) ?: return null
+ return try {
+ val sealed = Base64.decode(stored, Base64.NO_WRAP)
+ val iv = sealed.copyOfRange(0, 12)
+ val body = sealed.copyOfRange(12, sealed.size)
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(GCM_TAG_BITS, iv))
+ String(cipher.doFinal(body), Charsets.UTF_8)
+ } catch (e: Exception) {
+ // A restored backup or a cleared Keystore: the secret is gone, and the person pairs again.
+ null
+ }
+ }
+
+ private fun key(): SecretKey {
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ (store.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
+ val spec = KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build()
+ generator.init(spec)
+ return generator.generateKey()
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+package network.openvibe.link.service
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import network.openvibe.link.MainActivity
+import network.openvibe.link.R
+import network.openvibe.link.share.ShareUploader
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * The share sheet's upload, as a foreground service with progress: the person can leave the app while a photo or a
+ * file goes to their Download drive, and the notification says where it is.
+ */
+class ShareUploadService : Service() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ ensureChannel(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val uris = intent?.getStringArrayListExtra(EXTRA_URIS).orEmpty()
+ val mime = intent?.getStringExtra(EXTRA_MIME).orEmpty().ifBlank { "application/octet-stream" }
+ val names = intent?.getStringArrayListExtra(EXTRA_NAMES).orEmpty()
+ StayConnectedService.startForegroundCompat(this, NOTIFICATION_ID, progress("Preparing…", 0, 1))
+ if (uris.isEmpty()) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+ scope.launch {
+ var sent = 0
+ var failed = 0
+ uris.forEachIndexed { index, raw ->
+ val uri = Uri.parse(raw)
+ try {
+ val result = ShareUploader.send(this@ShareUploadService, uri, mime) { done, total ->
+ notify(progress("Sending ${index + 1} of ${uris.size} · ${percent(done, total)}", index + 1, uris.size))
+ }
+ sent++
+ notify(progress("Sent ${result.name}", index + 1, uris.size))
+ } catch (e: Exception) {
+ failed++
+ notify(progress("Could not send ${names.getOrNull(index) ?: "a file"}: ${e.message ?: "error"}", index + 1, uris.size))
+ }
+ }
+ val title = when {
+ failed == 0 -> if (sent == 1) "Sent to OpenVibe" else "$sent files sent to OpenVibe"
+ sent == 0 -> "Nothing was sent"
+ else -> "$sent sent, $failed failed"
+ }
+ done(title, failed == 0)
+ stopForeground(STOP_FOREGROUND_DETACH)
+ stopSelf(startId)
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun progress(text: String, done: Int, total: Int): android.app.Notification {
+ val open = PendingIntent.getActivity(
+ this, 0, Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return NotificationCompat.Builder(this, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("OpenVibe · Download")
+ .setContentText(text)
+ .setProgress(total, done, false)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(open)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ private fun done(text: String, ok: Boolean): android.app.Notification {
+ val open = PendingIntent.getActivity(
+ this, 0, Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return NotificationCompat.Builder(this, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle(text)
+ .setContentText(if (ok) "In your Download drive on openvibe.download" else "Open the app to try again")
+ .setAutoCancel(true)
+ .setContentIntent(open)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build()
+ }
+
+ private fun notify(notification: android.app.Notification) {
+ getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun percent(done: Long, total: Long): String {
+ if (total <= 0) return ""
+ return "${(done * 100 / total).coerceIn(0, 100)}%"
+ }
+
+ companion object {
+ const val CHANNEL = "downloads"
+ private const val NOTIFICATION_ID = 1002
+ const val EXTRA_URIS = "network.openvibe.link.URIS"
+ const val EXTRA_NAMES = "network.openvibe.link.NAMES"
+ const val EXTRA_MIME = "network.openvibe.link.MIME"
+
+ private val nextId = AtomicInteger(1)
+
+ fun ensureChannel(context: Context) {
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+ if (manager.getNotificationChannel(CHANNEL) == null) {
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL, context.getString(R.string.channel_downloads), NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Files you send to your Download drive"
+ setShowBadge(false)
+ },
+ )
+ }
+ }
+
+ fun start(context: Context, uris: List<String>, names: List<String>, mime: String) {
+ ensureChannel(context)
+ val intent = Intent(context, ShareUploadService::class.java)
+ .putStringArrayListExtra(EXTRA_URIS, ArrayList(uris))
+ .putStringArrayListExtra(EXTRA_NAMES, ArrayList(names))
+ .putExtra(EXTRA_MIME, mime)
+ ContextCompat.startForegroundService(context, intent)
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+package network.openvibe.link.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import network.openvibe.link.MainActivity
+import network.openvibe.link.R
+import network.openvibe.link.data.LinkController
+import network.openvibe.link.data.Store
+
+/**
+ * "Stay connected": the phone's one outbound connection to OpenVibe.Bot, held by an opt-in foreground service with a
+ * notification the person can always see and stop (the same rule as the plan's "always visible", without any phone
+ * automation). Nothing else runs here: no camera, no screen capture, no accessibility.
+ */
+class StayConnectedService : Service() {
+ private val main = Handler(Looper.getMainLooper())
+ private var tick: Runnable? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ ensureChannel(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) {
+ LinkController.stop()
+ Store.keepConnected(false)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ Store.keepConnected(true)
+ startForegroundCompat(this, NOTIFICATION_ID, notification("Connecting to OpenVibe…"))
+ LinkController.start()
+ watch()
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ tick?.let { main.removeCallbacks(it) }
+ tick = null
+ if (!Store.stayConnected) LinkController.stop()
+ super.onDestroy()
+ }
+
+ private fun watch() {
+ tick?.let { main.removeCallbacks(it) }
+ val runnable = object : Runnable {
+ override fun run() {
+ val text = when {
+ LinkController.connected -> "Connected as ${Store.deviceLabel.ifBlank { "this phone" }}"
+ LinkController.state == "revoked" -> "This phone was unpaired: pair it again"
+ else -> "Offline. Reconnecting…"
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.notify(NOTIFICATION_ID, notification(text))
+ if (LinkController.state != "revoked") main.postDelayed(this, 5_000)
+ }
+ }
+ tick = runnable
+ main.post(runnable)
+ }
+
+ private fun notification(text: String): Notification {
+ val open = PendingIntent.getActivity(
+ this, 0, Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val stop = PendingIntent.getService(
+ this, 1, Intent(this, StayConnectedService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return NotificationCompat.Builder(this, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("OpenVibe Link")
+ .setContentText(text)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(open)
+ .addAction(0, "Stop", stop)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ companion object {
+ const val ACTION_START = "network.openvibe.link.STAY_START"
+ const val ACTION_STOP = "network.openvibe.link.STAY_STOP"
+ const val CHANNEL = "stay_connected"
+ private const val NOTIFICATION_ID = 1001
+
+ fun ensureChannel(context: Context) {
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+ if (manager.getNotificationChannel(CHANNEL) == null) {
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL, context.getString(R.string.channel_stay_connected), NotificationManager.IMPORTANCE_LOW).apply {
+ description = "The phone's connection to OpenVibe"
+ setShowBadge(false)
+ },
+ )
+ }
+ }
+
+ fun start(context: Context) {
+ ensureChannel(context)
+ val intent = Intent(context, StayConnectedService::class.java).setAction(ACTION_START)
+ ContextCompat.startForegroundService(context, intent)
+ }
+
+ fun stop(context: Context) {
+ context.startService(Intent(context, StayConnectedService::class.java).setAction(ACTION_STOP))
+ context.stopService(Intent(context, StayConnectedService::class.java))
+ }
+
+ fun startForegroundCompat(service: Service, id: Int, notification: Notification) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ service.startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ service.startForeground(id, notification)
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+package network.openvibe.link.share
+
+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.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * The share sheet's question, asked before anything leaves the phone: a share intent is another app's request, not the
+ * person's decision. Send starts the upload; Cancel closes the sheet and the source app comes back with the file
+ * untouched.
+ */
+@Composable
+fun ConfirmShare(request: ShareRequest, onSend: () -> Unit, onCancel: () -> Unit) {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ Column(Modifier.fillMaxWidth().padding(24.dp)) {
+ Text(request.question, style = MaterialTheme.typography.headlineSmall)
+ Text(
+ "It goes to your private Download drive, as you. Nothing is re-encoded.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
+ )
+ request.text?.let {
+ Text(
+ it.take(140),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 20.dp),
+ )
+ }
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Button(onClick = onSend) { Text("Send") }
+ Spacer(Modifier.width(12.dp))
+ TextButton(onClick = onCancel) { Text("Cancel") }
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+package network.openvibe.link.share
+
+/**
+ * What a share sheet target received, without an Android type in it so the reading rules can be tested on the JVM.
+ *
+ * `uris` are content URIs as strings (the app turns them back into Uri when it opens them). `text` is the pasted
+ * text of a text share; a share with both files and text sends the files and keeps the text as the name.
+ */
+data class ShareRequest(
+ val mimeType: String,
+ val uris: List<String>,
+ val text: String?,
+) {
+ val isMultiple: Boolean get() = uris.size > 1
+ val hasFiles: Boolean get() = uris.isNotEmpty()
+
+ /** The question the sheet asks before anything leaves the phone. */
+ val question: String get() = when {
+ isMultiple -> "Send these ${uris.size} files to your Download?"
+ hasFiles -> "Send this to your Download?"
+ else -> "Send this text to your Download?"
+ }
+}
+
+object ShareIntents {
+ const val ACTION_SEND = "android.intent.action.SEND"
+ const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
+ const val FALLBACK_MIME = "application/octet-stream"
+ const val TEXT_MIME = "text/plain"
+
+ /**
+ * Read one share. Null means there is nothing to send (or it is not a share at all), so the app can say so
+ * instead of opening an empty upload.
+ */
+ fun parse(action: String?, mimeType: String?, uris: List<String>, text: String?): ShareRequest? {
+ val multiple = when (action) {
+ ACTION_SEND -> false
+ ACTION_SEND_MULTIPLE -> true
+ else -> return null
+ }
+ val files = uris.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
+ val pasted = text?.trim()?.takeIf { it.isNotEmpty() }
+ if (files.isEmpty() && pasted == null) return null
+
+ val mime = mimeType?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "*/*" }
+ ?: if (files.isEmpty()) TEXT_MIME else FALLBACK_MIME
+ val chosen = if (multiple) files else files.take(1)
+ return ShareRequest(mimeType = mime, uris = chosen, text = pasted)
+ }
+
+ /** A pasted text has no file name: the first line, clipped, is the file's name. */
+ fun nameFor(text: String?): String? {
+ val first = text?.lineSequence()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ return (first.take(60) + if (first.length > 60) "…" else "") + ".txt"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+package network.openvibe.link.share
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import network.openvibe.link.data.Api
+import network.openvibe.link.data.ApiException
+import network.openvibe.link.data.Session
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * "Send to OpenVibe": the drive's own upload, the same three steps the web page makes to OpenVibe.MediaHub
+ * (POST /api/v1/files → PUT /api/v1/uploads/:id/parts/:n → POST …/complete), with the person's Network token as the
+ * Bearer. The bytes go straight from the phone: nothing is re-encoded, and the file is private until the person
+ * shares it on openvibe.download.
+ */
+object ShareUploader {
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(120, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ private val JSON = "application/json; charset=utf-8".toMediaType()
+ private val OCTETS = "application/octet-stream".toMediaType()
+
+ class Sent(val fileId: String?, val name: String)
+
+ /** Size and display name of a shared content Uri, as the system reports them. */
+ fun describe(context: Context, uri: Uri): Pair<Long, String> {
+ var name = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "file"
+ var size = -1L
+ try {
+ context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c ->
+ if (c.moveToFirst()) {
+ val nameIndex = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val sizeIndex = c.getColumnIndex(OpenableColumns.SIZE)
+ if (nameIndex >= 0) name = c.getString(nameIndex)?.takeIf { it.isNotBlank() } ?: name
+ if (sizeIndex >= 0 && !c.isNull(sizeIndex)) size = c.getLong(sizeIndex)
+ }
+ }
+ } catch (e: Exception) {
+ // A provider that refuses metadata: the stream's own size is asked for below.
+ }
+ if (size <= 0) {
+ size = try {
+ context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } ?: -1L
+ } catch (e: Exception) {
+ -1L
+ }
+ }
+ if (size <= 0) throw ApiException("This file could not be read.")
+ return size to name
+ }
+
+ suspend fun send(
+ context: Context,
+ uri: Uri,
+ mimeType: String,
+ onProgress: (sent: Long, total: Long) -> Unit,
+ ): Sent = withContext(Dispatchers.IO) {
+ val token = Session.token() ?: throw ApiException("Sign in to OpenVibe to send files.")
+ val (size, name) = describe(context, uri)
+ val upload = start(token, name, size, mimeType)
+
+ val input = context.contentResolver.openInputStream(uri)
+ ?: throw ApiException("This file could not be opened.")
+ input.use { stream ->
+ var sent = 0L
+ for (part in 1..upload.parts) {
+ val length = if (part < upload.parts) upload.partSize else (size - upload.partSize * (upload.parts - 1))
+ val bytes = readExactly(stream, length.toInt())
+ if (bytes.size != length.toInt()) throw ApiException("The file changed while it was being sent.")
+ putPart(token, upload.id, part, bytes)
+ sent += length
+ onProgress(sent, size)
+ }
+ }
+ val fileId = complete(token, upload.id)
+ Sent(fileId, name)
+ }
+
+ private class Started(val id: String, val partSize: Long, val parts: Long)
+
+ private fun start(token: String, name: String, size: Long, mimeType: String): Started {
+ val body = JSONObject()
+ .put("name", name)
+ .put("size", size)
+ .put("content_type", mimeType.ifBlank { "application/octet-stream" })
+ .toString()
+ .toRequestBody(JSON)
+ val request = Request.Builder()
+ .url("${Api.DOWNLOAD}/api/v1/files")
+ .header("Authorization", "Bearer $token")
+ .header("Accept", "application/json")
+ .post(body)
+ .build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (response.code == 401) throw ApiException("Your session ended. Sign in again.")
+ if (!response.isSuccessful) throw ApiException(problem(text) ?: "The drive refused the upload (${response.code}).")
+ val upload = JSONObject(text).optJSONObject("upload")
+ ?: throw ApiException("The drive answered no upload.")
+ val id = upload.optString("id")
+ if (id.isEmpty()) throw ApiException("The drive answered no upload.")
+ return Started(id, upload.optLong("part_size", 8 * 1024 * 1024L), upload.optLong("parts_expected", 1L))
+ }
+ }
+
+ private fun putPart(token: String, uploadId: String, part: Long, bytes: ByteArray) {
+ val request = Request.Builder()
+ .url("${Api.DOWNLOAD}/api/v1/uploads/$uploadId/parts/$part")
+ .header("Authorization", "Bearer $token")
+ .put(bytes.toRequestBody(OCTETS))
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ val text = response.body?.string().orEmpty()
+ throw ApiException(problem(text) ?: "A part of the file was refused (${response.code}).")
+ }
+ }
+ }
+
+ private fun complete(token: String, uploadId: String): String? {
+ val request = Request.Builder()
+ .url("${Api.DOWNLOAD}/api/v1/uploads/$uploadId/complete")
+ .header("Authorization", "Bearer $token")
+ .header("Accept", "application/json")
+ .post("{}".toRequestBody(JSON))
+ .build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (!response.isSuccessful) throw ApiException(problem(text) ?: "The drive could not finish the upload (${response.code}).")
+ return try {
+ JSONObject(text).optJSONObject("file")?.optString("id")?.takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+
+ private fun readExactly(stream: InputStream, length: Int): ByteArray {
+ val out = ByteArray(length)
+ var read = 0
+ while (read < length) {
+ val n = stream.read(out, read, length - read)
+ if (n < 0) break
+ read += n
+ }
+ return if (read == length) out else out.copyOf(read)
+ }
+
+ private fun problem(text: String): String? = try {
+ JSONObject(text).optString("detail").takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ null
+ }
+
+ /** A pasted text becomes a small file in the app's cache, named after its first line. */
+ fun textFile(context: Context, text: String): Pair<Uri, String> {
+ val name = ShareIntents.nameFor(text) ?: "note.txt"
+ val file = java.io.File(context.cacheDir, "share-${System.currentTimeMillis()}-$name")
+ file.writeText(text)
+ return Uri.fromFile(file) to name
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+package network.openvibe.link.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import network.openvibe.link.data.ActionItem
+import network.openvibe.link.data.Store
+
+/** The words and the dot for each bucket of the Action Center. */
+private fun bucketLook(bucket: String): Pair<String, Tone> = when (bucket.lowercase()) {
+ "running", "active" -> "Running" to Tone.Ok
+ "waiting", "pending" -> "Waiting for you" to Tone.Warn
+ "scheduled", "later" -> "Scheduled" to Tone.Neutral
+ "failed", "error" -> "Did not finish" to Tone.Bad
+ "done", "finished", "succeeded" -> "Done" to Tone.Info
+ else -> bucket.replaceFirstChar { it.uppercase() } to Tone.Neutral
+}
+
+/** The order the groups read in: what is happening now first, what is done last. */
+private val ORDER = listOf("running", "active", "waiting", "pending", "scheduled", "later", "failed", "error", "done", "finished", "succeeded")
+
+/** The Dock's timeline, read-only: everything your agents are doing, from the Action Center, grouped by state. */
+@Composable
+fun ActivityScreen(preview: List<ActionItem>? = null) {
+ LaunchedEffect(Store.signedIn) {
+ if (preview == null && Store.signedIn) ActionsRepo.refresh()
+ }
+
+ if (preview == null && !Store.signedIn) {
+ SignInCard("Activity")
+ return
+ }
+
+ val items = preview ?: ActionsRepo.activity
+ val firstLoad = preview == null && ActionsRepo.loading && ActionsRepo.doc == null
+ val groups = items.groupBy { it.bucket.lowercase() }.toList()
+ .sortedBy { (b, _) -> ORDER.indexOf(b).let { if (it < 0) ORDER.size else it } }
+
+ LinkPage(title = "Activity", lead = "What your agents are running, waiting on and finished. Answer approvals on their own tab.") {
+ ActionsRepo.error?.let { err ->
+ item { Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
+ }
+ if (firstLoad) {
+ skeletonCards(2)
+ return@LinkPage
+ }
+ if (items.isEmpty()) {
+ item { EmptyState(Icons.Outlined.History, "Nothing yet", "When an agent starts a task for you, it shows here as it runs, with what it is waiting on.") }
+ return@LinkPage
+ }
+ groups.forEach { (bucket, rows) ->
+ val (label, tone) = bucketLook(bucket)
+ item(key = "h-$bucket") {
+ Row(Modifier.padding(top = 10.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ StatusDot(tone)
+ Text("$label · ${rows.size}", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ items(rows, key = { it.service + it.id + (it.updatedAt ?: "") }) { ActivityRow(it) }
+ }
+ }
+}
+
+/** One thing an agent is doing: its sentence, the latest line, and who and when. */
+@Composable
+fun ActivityRow(item: ActionItem) {
+ LinkCard {
+ Text(item.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
+ item.summary?.takeIf { it.isNotBlank() && it != item.title }?.let {
+ Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ val who = listOfNotNull(item.agent, (item.updatedAt ?: item.createdAt)?.let { shortTime(it) }).joinToString(" · ")
+ if (who.isNotEmpty()) {
+ Column(Modifier.fillMaxWidth()) {
+ Text(who, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+package network.openvibe.link.ui
+
+import android.net.Uri
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material.icons.outlined.Login
+import androidx.compose.material.icons.outlined.PhoneAndroid
+import androidx.compose.material.icons.outlined.TaskAlt
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import network.openvibe.link.data.Servers
+import network.openvibe.link.data.Session
+import network.openvibe.link.data.Store
+
+/** The app: pair first, then the three screens of a paired phone. */
+@Composable
+fun LinkApp() {
+ val context = LocalContext.current
+
+ val authUri = Incoming.authUri
+ LaunchedEffect(authUri) {
+ val raw = authUri ?: return@LaunchedEffect
+ try {
+ Session.complete(Uri.parse(raw))
+ Messages.show("Signed in.")
+ ActionsRepo.refresh()
+ } catch (e: Exception) {
+ Messages.show(e.message ?: "Sign-in failed.")
+ }
+ Incoming.authUri = null
+ }
+
+ LaunchedEffect(Messages.text) {
+ val message = Messages.text ?: return@LaunchedEffect
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ Messages.text = null
+ }
+
+ Column(Modifier.fillMaxSize()) {
+ // A debug build pointed at another server says so on every screen: nothing here is production.
+ if (Servers.isDebugOverride) {
+ Text(
+ "Debug server · ${Servers.host}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.tertiaryContainer)
+ .padding(horizontal = 16.dp, vertical = 6.dp),
+ )
+ }
+ Box(Modifier.weight(1f)) {
+ if (Store.paired == null) {
+ PairScreen(initialCode = Incoming.pairCode)
+ } else {
+ MainScreen()
+ }
+ }
+ }
+}
+
+@Composable
+private fun MainScreen() {
+ var tab by rememberSaveable { mutableIntStateOf(0) }
+ val tabs = listOf("Approvals" to Icons.Outlined.TaskAlt, "Activity" to Icons.Outlined.History, "This phone" to Icons.Outlined.PhoneAndroid)
+ Scaffold(
+ bottomBar = {
+ NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
+ tabs.forEachIndexed { index, (label, icon) ->
+ NavigationBarItem(
+ selected = tab == index,
+ onClick = { tab = index },
+ icon = { Icon(icon, contentDescription = null) },
+ label = { Text(label) },
+ )
+ }
+ }
+ },
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding)) {
+ when (tab) {
+ 0 -> ApprovalsScreen()
+ 1 -> ActivityScreen()
+ else -> PhoneScreen()
+ }
+ }
+ }
+}
+
+/** A session and a paired phone are both needed for the two read screens: one card, one button. */
+@Composable
+fun SignInCard(title: String = "Your OpenVibe account") {
+ val context = LocalContext.current
+ LinkPage(title = title, lead = null) {
+ item {
+ LinkCard {
+ EmptyState(
+ Icons.Outlined.Login,
+ "Sign in to see what your agents need",
+ "The phone keeps a token from OpenVibe.Network, sealed with a key only this phone's Keystore holds.",
+ )
+ Button(
+ onClick = { if (!Session.signIn(context)) Messages.show("No browser answered. Install one and try again.") },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Sign in to OpenVibe") }
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+package network.openvibe.link.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.DeleteOutline
+import androidx.compose.material.icons.outlined.Fingerprint
+import androidx.compose.material.icons.outlined.Key
+import androidx.compose.material.icons.outlined.Payments
+import androidx.compose.material.icons.outlined.PersonAddAlt
+import androidx.compose.material.icons.outlined.TaskAlt
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import kotlinx.coroutines.launch
+import network.openvibe.link.approval.ApprovalCategory
+import network.openvibe.link.approval.ApprovalRules
+import network.openvibe.link.approval.Biometrics
+import network.openvibe.link.data.ActionItem
+import network.openvibe.link.data.Store
+
+/** Each category's tone and icon: money amber, deleting red, passwords violet, a new recipient blue. */
+private fun ApprovalCategory.tone(): Tone = when (this) {
+ ApprovalCategory.MONEY -> Tone.Warn
+ ApprovalCategory.DELETE -> Tone.Bad
+ ApprovalCategory.PASSWORD -> Tone.Secret
+ ApprovalCategory.NEW_RECIPIENT -> Tone.Info
+ ApprovalCategory.OTHER -> Tone.Neutral
+}
+
+private fun ApprovalCategory.icon(): ImageVector = when (this) {
+ ApprovalCategory.MONEY -> Icons.Outlined.Payments
+ ApprovalCategory.DELETE -> Icons.Outlined.DeleteOutline
+ ApprovalCategory.PASSWORD -> Icons.Outlined.Key
+ ApprovalCategory.NEW_RECIPIENT -> Icons.Outlined.PersonAddAlt
+ ApprovalCategory.OTHER -> Icons.Outlined.Tune
+}
+
+/**
+ * Actor's pending approvals for the person. Approve or decline; money, passwords, deletions and a send to a new
+ * recipient ask for a fingerprint, a face or the device credential first, and only then send the answer. Each card
+ * leads with what it is about (a tinted chip), then the agent's sentence, then who asked and when, then the answer.
+ */
+@Composable
+fun ApprovalsScreen(preview: List<ActionItem>? = null) {
+ val context = LocalContext.current
+ val activity = context as? FragmentActivity
+ val scope = rememberCoroutineScope()
+ var working by remember { mutableStateOf<String?>(null) }
+
+ LaunchedEffect(Store.signedIn) {
+ if (preview == null && Store.signedIn) ActionsRepo.refresh()
+ }
+
+ if (preview == null && !Store.signedIn) {
+ SignInCard("Approvals")
+ return
+ }
+
+ val waiting = preview ?: ActionsRepo.approvals
+ val firstLoad = preview == null && ActionsRepo.loading && ActionsRepo.doc == null
+
+ LinkPage(
+ title = "Approvals",
+ lead = when {
+ firstLoad -> "Asking your agents what is waiting…"
+ waiting.isEmpty() -> null
+ waiting.size == 1 -> "One thing is waiting for your answer."
+ else -> "${waiting.size} things are waiting for your answer."
+ },
+ ) {
+ ActionsRepo.error?.let { err ->
+ item { Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
+ }
+ if (firstLoad) {
+ skeletonCards(3)
+ return@LinkPage
+ }
+ if (waiting.isEmpty()) {
+ item {
+ EmptyState(
+ Icons.Outlined.TaskAlt,
+ "Nothing is waiting for you",
+ "When an agent wants to pay, delete, sign in or write to someone new, it asks here first.",
+ )
+ }
+ return@LinkPage
+ }
+
+ items(waiting, key = { it.service + it.id }) { item ->
+ val category = ApprovalRules.classify(item.category, item.kind, item.title, item.summary)
+ val decide: (Boolean) -> Unit = { approve ->
+ val send = {
+ working = item.id
+ scope.launch {
+ try {
+ ActionsRepo.decide(item, approve)
+ } catch (e: Exception) {
+ Messages.show(e.message ?: "The answer was not sent.")
+ } finally {
+ working = null
+ }
+ }
+ Unit
+ }
+ if (category.needsBiometrics && activity != null) {
+ Biometrics.confirm(
+ activity,
+ "${if (approve) category.label else "Declining"} · ${item.title}",
+ onSuccess = { send() },
+ onError = { Messages.show(it) },
+ )
+ } else {
+ send()
+ }
+ }
+ LinkCard(accent = if (category.needsBiometrics) toneColor(category.tone()) else null) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ ToneChip(category.label, category.tone(), category.icon())
+ if (category.needsBiometrics) {
+ Icon(Icons.Outlined.Fingerprint, contentDescription = "Asks for your fingerprint or face", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
+ }
+ }
+ Text(item.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
+ item.summary?.takeIf { it.isNotBlank() && it != item.title }?.let {
+ Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ val who = listOfNotNull(item.agent, item.createdAt?.let { shortTime(it) }).joinToString(" · ")
+ if (who.isNotEmpty()) {
+ Text(who, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ enabled = working == null,
+ onClick = { decide(true) },
+ modifier = Modifier.weight(1f).heightIn(min = 48.dp),
+ ) { Text(if (working == item.id) "Sending…" else "Approve", fontWeight = FontWeight.Bold) }
+ OutlinedButton(
+ enabled = working == null,
+ onClick = { decide(false) },
+ modifier = Modifier.weight(1f).heightIn(min = 48.dp),
+ ) { Text("Decline") }
+ }
+ }
+ }
+ }
+}
+
+/** "2026-11-07T15:00:00.000Z" → "7 Nov 15:00". A value the formatter cannot read is shown as it came. */
+fun shortTime(iso: String): String = try {
+ val instant = java.time.Instant.parse(iso)
+ val local = instant.atZone(java.time.ZoneId.systemDefault())
+ java.time.format.DateTimeFormatter.ofPattern("d MMM HH:mm").format(local)
+} catch (e: Exception) {
+ iso.take(16).replace('T', ' ')
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+package network.openvibe.link.ui
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+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.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/*
+ * Link's small kit (Opus, 11-07): the pieces every screen is made of, in OpenVibe's look — a page with its title and one
+ * line under it, a card on the raised surface with a hairline, a chip whose tone says how much a thing matters, a
+ * status dot, a still skeleton while the first answer loads (no spinning ring: the owner's rule), and an empty state
+ * that says what will appear and why. Colours come only from the theme (LinkTheme).
+ */
+
+/** How much a thing matters: the tone of a chip or a dot. */
+enum class Tone { Neutral, Info, Ok, Warn, Bad, Secret }
+
+@Composable
+fun toneColor(tone: Tone): Color = when (tone) {
+ Tone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
+ Tone.Info -> MaterialTheme.colorScheme.tertiary
+ Tone.Ok -> Color(0xFF22C55E)
+ Tone.Warn -> Color(0xFFF59E0B)
+ Tone.Bad -> MaterialTheme.colorScheme.error
+ Tone.Secret -> Color(0xFFA78BFA)
+}
+
+/** A screen: its title and one line, then the content, as a list that scrolls under the bar. */
+@Composable
+fun LinkPage(title: String, lead: String?, modifier: Modifier = Modifier, content: LazyListScope.() -> Unit) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
+ contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Column(Modifier.padding(bottom = 6.dp)) {
+ Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)
+ if (!lead.isNullOrBlank()) {
+ Text(lead, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp))
+ }
+ }
+ }
+ content()
+ }
+}
+
+/** A card on the raised surface, with a hairline and the house's 18 dp corners. */
+@Composable
+fun LinkCard(modifier: Modifier = Modifier, accent: Color? = null, content: @Composable ColumnScope.() -> Unit) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(18.dp),
+ color = MaterialTheme.colorScheme.surface,
+ border = BorderStroke(1.dp, accent?.copy(alpha = 0.45f) ?: MaterialTheme.colorScheme.outlineVariant),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content)
+ }
+}
+
+/** A small pill: an icon and a word, tinted by its tone. */
+@Composable
+fun ToneChip(text: String, tone: Tone, icon: ImageVector? = null) {
+ val c = toneColor(tone)
+ Row(
+ Modifier.background(c.copy(alpha = 0.14f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ if (icon != null) Icon(icon, contentDescription = null, tint = c, modifier = Modifier.size(14.dp))
+ Text(text, color = c, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
+/** A dot that says the state of a thing (green running, amber waiting, grey later). Still: it never pulses. */
+@Composable
+fun StatusDot(tone: Tone, modifier: Modifier = Modifier) {
+ Box(modifier.size(10.dp).background(toneColor(tone), CircleShape))
+}
+
+/** The first answer is on its way: still blocks where cards will be. */
+fun LazyListScope.skeletonCards(count: Int = 3) {
+ items(count) {
+ LinkCard {
+ Box(Modifier.fillMaxWidth(0.7f).height(16.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)))
+ Box(Modifier.fillMaxWidth(0.9f).height(12.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)))
+ Spacer(Modifier.height(4.dp))
+ Box(Modifier.width(96.dp).height(12.dp).background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp)))
+ }
+ }
+}
+
+/** Nothing here yet: an icon, a sentence that says what will appear, and why it is empty. */
+@Composable
+fun EmptyState(icon: ImageVector, title: String, body: String) {
+ Column(
+ Modifier.fillMaxWidth().padding(vertical = 36.dp, horizontal = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ Modifier.size(64.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(30.dp)) }
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)
+ Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
+ }
+}
+
+/** A label and its value on one line, the label muted. */
+@Composable
+fun LabelledRow(label: String, value: String) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.38f))
+ Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.62f))
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+package network.openvibe.link.ui
+
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.QrCodeScanner
+import androidx.compose.material3.Icon
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.text.font.FontWeight
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+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.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import kotlinx.coroutines.launch
+import network.openvibe.link.data.Api
+import network.openvibe.link.data.PairLinks
+import network.openvibe.link.data.Store
+
+/**
+ * The first screen: the code from openvibe.bot/link/phone, typed or scanned. Pairing is the Node's own: the code is
+ * redeemed on OpenVibe.Network and the phone is bound on OpenVibe.Bot as a device of kind "phone".
+ */
+@Composable
+fun PairScreen(initialCode: String?) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var code by rememberSaveable { mutableStateOf(initialCode.orEmpty()) }
+ var pairing by rememberSaveable { mutableStateOf<String?>(null) }
+ var busy by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf<String?>(null) }
+
+ LaunchedEffect(initialCode) {
+ if (!initialCode.isNullOrBlank()) code = initialCode
+ }
+ // A link that named another server was refused before it got here (MainActivity, PairLinks): say which, plainly.
+ LaunchedEffect(Incoming.pairRefused) {
+ val refused = Incoming.pairRefused ?: return@LaunchedEffect
+ error = "That link points at $refused, not OpenVibe Network. Only a code from openvibe.bot/link/phone pairs this phone."
+ Incoming.pairRefused = null
+ }
+
+ val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
+ val link = PairLinks.codeFromScan(result.contents)
+ when {
+ link?.refusedServer != null -> {
+ error = "That QR points at ${link.refusedServer}, not OpenVibe Network."
+ Messages.show("That QR points at ${link.refusedServer}, not OpenVibe Network.")
+ }
+ link?.code != null -> {
+ code = link.code
+ pairing = link.pairing
+ }
+ result.contents != null -> Messages.show("That QR is not an OpenVibe pairing link.")
+ }
+ }
+ val askCamera = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) launchScanner(scanner) else Messages.show("The camera is how the QR is read; you can type the code instead.")
+ }
+
+ Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp)) {
+ Text("Link this phone", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)
+ Text(
+ "Your agents' approvals in your pocket, and Send to OpenVibe from any app.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
+ )
+ LinkCard {
+ PairStep(1, "On your computer, open openvibe.bot/link/phone, signed in as you.")
+ PairStep(2, "Scan the QR it shows, or type its code below.")
+ }
+ Button(
+ onClick = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ launchScanner(scanner)
+ } else {
+ askCamera.launch(Manifest.permission.CAMERA)
+ }
+ },
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp).heightIn(min = 52.dp),
+ ) {
+ Icon(Icons.Outlined.QrCodeScanner, contentDescription = null, modifier = Modifier.padding(end = 10.dp))
+ Text("Scan the QR", fontWeight = FontWeight.Bold)
+ }
+ Text(
+ "Or type the code",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
+ )
+
+ OutlinedTextField(
+ value = code,
+ onValueChange = { code = it.uppercase() },
+ label = { Text("Pairing code") },
+ placeholder = { Text("ABCD-1234") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ error?.let {
+ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 12.dp))
+ }
+
+ FilledTonalButton(
+ onClick = {
+ if (busy) return@FilledTonalButton
+ busy = true
+ error = null
+ scope.launch {
+ try {
+ val name = "Link · ${Build.MODEL ?: "phone"}"
+ val creds = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ mobile.Mobile.pair(Api.NETWORK, Api.BOT, code, name, pairing ?: "")
+ }
+ Store.savePairing(creds, name)
+ Messages.show("This phone is linked.")
+ } catch (e: Exception) {
+ error = e.message ?: "Pairing failed."
+ } finally {
+ busy = false
+ }
+ }
+ },
+ enabled = !busy && code.isNotBlank(),
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp),
+ ) {
+ Text(if (busy) "Pairing…" else "Pair this phone")
+ }
+
+
+ Text(
+ "A code works once and lasts 15 minutes. Nothing on this phone is backed up: the pairing is encrypted with a key this phone's Keystore holds.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 24.dp),
+ )
+ }
+}
+
+private fun launchScanner(scanner: androidx.activity.result.ActivityResultLauncher<ScanOptions>) {
+ scanner.launch(
+ ScanOptions()
+ .setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ .setPrompt("Scan the QR on openvibe.bot/link/phone")
+ .setBeepEnabled(false)
+ .setOrientationLocked(false),
+ )
+}
+
+/** One numbered step of pairing. */
+@Composable
+private fun PairStep(n: Int, text: String) {
+ Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Box(
+ Modifier.size(26.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) { Text("$n", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelLarge) }
+ Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 3.dp))
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+package network.openvibe.link.ui
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.PhoneAndroid
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import network.openvibe.link.data.LinkController
+import network.openvibe.link.data.Session
+import network.openvibe.link.data.Store
+import network.openvibe.link.service.StayConnectedService
+import org.json.JSONObject
+
+/** "This phone": what it is paired as, Stay connected, sign-in, and Unpair. */
+@Composable
+fun PhoneScreen(previewInfo: JSONObject? = null, preview: Boolean = false) {
+ val context = LocalContext.current
+ val info = previewInfo ?: remember(Store.paired) { LinkController.info()?.let { runCatching { JSONObject(it) }.getOrNull() } }
+ var confirmUnpair by remember { mutableStateOf(false) }
+
+ var showIds by remember { mutableStateOf(false) }
+ Column(
+ Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text("This phone", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)
+ Text(
+ "One of your devices. It has no camera or screen access: in the background it only keeps one connection open.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ LinkCard {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Box(
+ Modifier.size(48.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), RoundedCornerShape(14.dp)),
+ contentAlignment = Alignment.Center,
+ ) { Icon(Icons.Outlined.PhoneAndroid, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }
+ Column(Modifier.weight(1f)) {
+ Text(Store.deviceLabel.ifBlank { "This phone" }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ StatusDot(if (LinkController.state == "connected") Tone.Ok else if (LinkController.state == "revoked") Tone.Bad else Tone.Neutral)
+ Text(statusLine(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+ TextButton(onClick = { showIds = !showIds }, contentPadding = PaddingValues(0.dp)) {
+ Text(if (showIds) "Hide the details" else "Details: device and node ids")
+ }
+ if (showIds) {
+ LabelledRow("Device", info?.optString("device_id")?.takeIf { it.isNotEmpty() } ?: "Binding…")
+ LabelledRow("Kind", info?.optString("kind") ?: "phone")
+ info?.optString("network")?.takeIf { it.isNotEmpty() }?.let { LabelledRow("Network", it) }
+ val node = info?.optString("principal").orEmpty()
+ if (node.isNotEmpty()) LabelledRow("Node", node)
+ }
+ }
+
+ LinkCard {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(Modifier.weight(1f)) {
+ Text("Stay connected", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
+ Text(
+ "Approvals arrive even when the app is closed, with a notification you can always see. Off, the phone connects only while the app is open.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = Store.stayConnected,
+ onCheckedChange = { on ->
+ Store.keepConnected(on)
+ if (on) StayConnectedService.start(context) else StayConnectedService.stop(context)
+ },
+ )
+ }
+ }
+
+ if (Store.signedIn) {
+ Text("Signed in to your OpenVibe account.", style = MaterialTheme.typography.bodyMedium)
+ OutlinedButton(
+ onClick = {
+ Session.signOut()
+ ActionsRepo.clear()
+ },
+ ) { Text("Sign out") }
+ } else {
+ Button(
+ onClick = {
+ if (!Session.signIn(context)) Messages.show("No browser answered. Install one and try again.")
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Sign in to OpenVibe") }
+ }
+
+ OutlinedButton(
+ onClick = { confirmUnpair = true },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)),
+ ) {
+ Text("Unpair this phone")
+ }
+ Text(
+ "Unpairing forgets the phone's credential on this device. Remove the device on openvibe.bot as well if you want it gone from your account.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+
+ if (confirmUnpair) {
+ AlertDialog(
+ onDismissRequest = { confirmUnpair = false },
+ title = { Text("Unpair this phone?") },
+ text = { Text("It stops receiving approvals and can no longer send files. Nothing else on the phone is deleted.") },
+ confirmButton = {
+ TextButton(onClick = {
+ confirmUnpair = false
+ StayConnectedService.stop(context)
+ Store.keepConnected(false)
+ LinkController.stop()
+ Session.signOut()
+ ActionsRepo.clear()
+ Store.forgetPairing()
+ }) { Text("Unpair") }
+ },
+ dismissButton = {
+ TextButton(onClick = { confirmUnpair = false }) { Text("Keep it") }
+ },
+ )
+ }
+}
+
+
+private fun statusLine(): String = when (LinkController.state) {
+ "connected" -> "Connected to OpenVibe."
+ "revoked" -> LinkController.detail ?: "This phone was unpaired. Pair it again."
+ "offline" -> "Offline. Reconnecting while Stay connected is on."
+ else -> "Not connected."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+package network.openvibe.link.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import network.openvibe.link.data.ActionItem
+import network.openvibe.link.data.ActionsDoc
+import network.openvibe.link.data.Api
+import network.openvibe.link.data.Session
+import network.openvibe.link.data.Store
+
+/** Intents the activity received, for the Compose tree to read (a deep link can arrive while it is already drawn). */
+object Incoming {
+ var pairCode: String? by mutableStateOf(null)
+ /** The server a refused pair link named, so the pair screen can say it instead of pairing with it. */
+ var pairRefused: String? by mutableStateOf(null)
+ var authUri: String? by mutableStateOf(null)
+}
+
+/** One short line for the person: a toast, then cleared. */
+object Messages {
+ var text: String? by mutableStateOf(null)
+
+ fun show(message: String?) {
+ if (!message.isNullOrBlank()) text = message
+ }
+}
+
+/**
+ * The person's Action Center, the same answer the Dock's activity tab and the bell read. It is cached in memory only
+ * for the life of the process; every screen that needs it asks for a refresh.
+ */
+object ActionsRepo {
+ var doc: ActionsDoc? by mutableStateOf(null)
+ private set
+ var loading: Boolean by mutableStateOf(false)
+ private set
+ var error: String? by mutableStateOf(null)
+ private set
+
+ val approvals: List<ActionItem>
+ get() = doc?.buckets?.values?.flatten()?.filter { it.isApproval && it.isPending }?.sortedByDescending { it.createdAt }.orEmpty()
+
+ val activity: List<ActionItem>
+ get() = doc?.buckets?.values?.flatten()?.sortedByDescending { it.updatedAt ?: it.createdAt }.orEmpty()
+
+ suspend fun refresh() {
+ if (!Store.signedIn) return
+ if (!Session.hasSession()) return
+ loading = true
+ error = null
+ try {
+ doc = Api.actions()
+ } catch (e: Exception) {
+ error = e.message ?: "Could not read your activity."
+ } finally {
+ loading = false
+ }
+ }
+
+ suspend fun decide(item: ActionItem, approve: Boolean) {
+ Api.decide(item, approve)
+ refresh()
+ }
+
+ fun clear() {
+ doc = null
+ error = null
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+package network.openvibe.link.ui
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import network.openvibe.theme.VibeDarkColorScheme
+import network.openvibe.theme.VibeDarkTokens
+import network.openvibe.theme.VibeLightColorScheme
+import network.openvibe.theme.VibeLightTokens
+
+/**
+ * The rest of OpenVibe, on the phone: the generated Vibe theme (openvibe-shared's native export), never hand-drawn.
+ *
+ * Material 3's container roles (cards, the bottom bar and its selected pill, dialogs, chips) are not in the export.
+ * Left alone they fall back to Material's own lavender, which is what the first screens showed. Here they take the
+ * Vibe surfaces, so nothing on screen is a colour OpenVibe does not use.
+ */
+@Composable
+fun LinkTheme(dark: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
+ val scheme = if (dark) {
+ VibeDarkColorScheme.copy(
+ surfaceContainerLowest = VibeDarkTokens.ColorBgBase,
+ surfaceContainerLow = VibeDarkTokens.ColorBgRaised,
+ surfaceContainer = VibeDarkTokens.ColorBgRaised,
+ surfaceContainerHigh = VibeDarkTokens.ColorBgSurface,
+ surfaceContainerHighest = VibeDarkTokens.ColorBgSubtle,
+ surfaceBright = VibeDarkTokens.ColorBgHover,
+ surfaceDim = VibeDarkTokens.ColorBgBase,
+ surfaceTint = VibeDarkTokens.ColorBgRaised,
+ secondaryContainer = VibeDarkTokens.ColorBgHover,
+ onSecondaryContainer = VibeDarkTokens.ColorAccentLight,
+ inverseSurface = VibeDarkTokens.ColorTextPrimary,
+ inverseOnSurface = VibeDarkTokens.ColorBgBase,
+ )
+ } else {
+ VibeLightColorScheme.copy(
+ surfaceContainerLowest = VibeLightTokens.ColorBgRaised,
+ surfaceContainerLow = VibeLightTokens.ColorBgRaised,
+ surfaceContainer = VibeLightTokens.ColorBgRaised,
+ surfaceContainerHigh = VibeLightTokens.ColorBgSurface,
+ surfaceContainerHighest = VibeLightTokens.ColorBgSubtle,
+ surfaceBright = VibeLightTokens.ColorBgRaised,
+ surfaceDim = VibeLightTokens.ColorBgSubtle,
+ surfaceTint = VibeLightTokens.ColorBgRaised,
+ secondaryContainer = VibeLightTokens.ColorBgHover,
+ onSecondaryContainer = VibeLightTokens.ColorAccentStrong,
+ inverseSurface = VibeLightTokens.ColorTextPrimary,
+ inverseOnSurface = VibeLightTokens.ColorBgRaised,
+ )
+ }
+ MaterialTheme(colorScheme = scheme, content = content)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+// Vibe (dark) — generated by openvibe-shared/tokens v1 from the OpenVibe theme "vibe". Regenerate instead of editing.
+package network.openvibe.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/** OpenVibe's default: deep blue night with an electric-blue glow. */
+object VibeDarkTokens {
+ val ColorBgBase = Color(0xFF0A0F1C)
+ val ColorBgSurface = Color(0xFF101828)
+ val ColorBgSubtle = Color(0xFF172238)
+ val ColorBgRaised = Color(0xFF131C2E)
+ val ColorBgHover = Color(0xFF1C2A44)
+ val ColorBgInput = Color(0xFF0D1424)
+ val ColorTextPrimary = Color(0xFFE6EDF7)
+ val ColorTextSecondary = Color(0xFF96A7C2)
+ val ColorTextMuted = Color(0xFF8192AC)
+ val ColorAccentBase = Color(0xFF3B82F6)
+ val ColorAccentLight = Color(0xFF60A5FA)
+ val ColorAccentDark = Color(0xFF1D4ED8)
+ val ColorAccentStrong = Color(0xFF3472D8)
+ val ColorAccentOn = Color(0xFFFFFFFF)
+ val ColorAccentOnStrong = Color(0xFFFFFFFF)
+ val ColorAccentGlow = Color(0x593B82F6)
+ val ColorLineBase = Color(0xFF1F2D47)
+ val ColorLineStrong = Color(0xFF2C3D5C)
+ val ColorStatusOkBase = Color(0xFF22C55E)
+ val ColorStatusOkText = Color(0xFF22C55E)
+ val ColorStatusWarnBase = Color(0xFFF59E0B)
+ val ColorStatusWarnText = Color(0xFFF59E0B)
+ val ColorStatusBadBase = Color(0xFFEF4444)
+ val ColorStatusBadText = Color(0xFFF26262)
+ val ColorStatusInfoBase = Color(0xFF38BDF8)
+ val ColorStatusInfoText = Color(0xFF38BDF8)
+ val ColorStatusLiveBase = Color(0xFFEF4444)
+ val ColorStatusLiveText = Color(0xFFF26262)
+}
+
+/** Material 3 roles: MaterialTheme(colorScheme = VibeDarkColorScheme) { … } */
+val VibeDarkColorScheme: ColorScheme = darkColorScheme(
+ primary = VibeDarkTokens.ColorAccentBase,
+ onPrimary = VibeDarkTokens.ColorAccentOn,
+ primaryContainer = VibeDarkTokens.ColorAccentStrong,
+ onPrimaryContainer = VibeDarkTokens.ColorAccentOnStrong,
+ secondary = VibeDarkTokens.ColorAccentLight,
+ onSecondary = VibeDarkTokens.ColorAccentOn,
+ tertiary = VibeDarkTokens.ColorStatusInfoBase,
+ background = VibeDarkTokens.ColorBgBase,
+ onBackground = VibeDarkTokens.ColorTextPrimary,
+ surface = VibeDarkTokens.ColorBgRaised,
+ onSurface = VibeDarkTokens.ColorTextPrimary,
+ surfaceVariant = VibeDarkTokens.ColorBgSurface,
+ onSurfaceVariant = VibeDarkTokens.ColorTextSecondary,
+ outline = VibeDarkTokens.ColorLineStrong,
+ outlineVariant = VibeDarkTokens.ColorLineBase,
+ error = VibeDarkTokens.ColorStatusBadBase,
+ onError = VibeDarkTokens.ColorAccentOnStrong,
+)
+
+/** OpenVibe's default in daylight: airy blue-white with the same blue. */
+object VibeLightTokens {
+ val ColorBgBase = Color(0xFFF0F5FF)
+ val ColorBgSurface = Color(0xFFE8EEF8)
+ val ColorBgSubtle = Color(0xFFDDE5F0)
+ val ColorBgRaised = Color(0xFFF5F8FF)
+ val ColorBgHover = Color(0xFFE4ECF8)
+ val ColorBgInput = Color(0xFFEDF2FA)
+ val ColorTextPrimary = Color(0xFF1A2040)
+ val ColorTextSecondary = Color(0xFF4A5070)
+ val ColorTextMuted = Color(0xFF5E637B)
+ val ColorAccentBase = Color(0xFF245DD5)
+ val ColorAccentLight = Color(0xFF3C669B)
+ val ColorAccentDark = Color(0xFF2563EB)
+ val ColorAccentStrong = Color(0xFF245DD5)
+ val ColorAccentOn = Color(0xFFFFFFFF)
+ val ColorAccentOnStrong = Color(0xFFFFFFFF)
+ val ColorAccentGlow = Color(0x59245DD5)
+ val ColorLineBase = Color(0xFFD0D8EA)
+ val ColorLineStrong = Color(0xFFBCC8E0)
+ val ColorStatusOkBase = Color(0xFF22C55E)
+ val ColorStatusOkText = Color(0xFF147237)
+ val ColorStatusWarnBase = Color(0xFFF59E0B)
+ val ColorStatusWarnText = Color(0xFF895806)
+ val ColorStatusBadBase = Color(0xFFEF4444)
+ val ColorStatusBadText = Color(0xFFBA3535)
+ val ColorStatusInfoBase = Color(0xFF38BDF8)
+ val ColorStatusInfoText = Color(0xFF1F6A8B)
+ val ColorStatusLiveBase = Color(0xFFEF4444)
+ val ColorStatusLiveText = Color(0xFFBA3535)
+}
+
+/** Material 3 roles: MaterialTheme(colorScheme = VibeLightColorScheme) { … } */
+val VibeLightColorScheme: ColorScheme = lightColorScheme(
+ primary = VibeLightTokens.ColorAccentBase,
+ onPrimary = VibeLightTokens.ColorAccentOn,
+ primaryContainer = VibeLightTokens.ColorAccentStrong,
+ onPrimaryContainer = VibeLightTokens.ColorAccentOnStrong,
+ secondary = VibeLightTokens.ColorAccentLight,
+ onSecondary = VibeLightTokens.ColorAccentOn,
+ tertiary = VibeLightTokens.ColorStatusInfoBase,
+ background = VibeLightTokens.ColorBgBase,
+ onBackground = VibeLightTokens.ColorTextPrimary,
+ surface = VibeLightTokens.ColorBgRaised,
+ onSurface = VibeLightTokens.ColorTextPrimary,
+ surfaceVariant = VibeLightTokens.ColorBgSurface,
+ onSurfaceVariant = VibeLightTokens.ColorTextSecondary,
+ outline = VibeLightTokens.ColorLineStrong,
+ outlineVariant = VibeLightTokens.ColorLineBase,
+ error = VibeLightTokens.ColorStatusBadBase,
+ onError = VibeLightTokens.ColorAccentOnStrong,
+)
+
+/** The OpenVibe kit's radius, space, size, type and motion scales: the same in every theme. */
+object OvScale {
+ val RadiusSm = 10.dp
+ val RadiusMd = 14.dp
+ val RadiusLg = 20.dp
+ val RadiusPill = 999.dp
+ val SpaceXs = 4.dp
+ val SpaceSm = 8.dp
+ val SpaceMd = 12.dp
+ val SpaceLg = 16.dp
+ val SpaceXl = 24.dp
+ val SpaceXxl = 32.dp
+ val SizeTap = 44.dp
+ val SizeIcon = 20.dp
+ val SizeAvatar = 34.dp
+ val TypeSizeCaption = 12.sp
+ val TypeSizeMeta = 13.sp
+ val TypeSizeBody = 15.sp
+ val TypeSizeLead = 17.sp
+ val TypeSizeTitle = 20.sp
+ val TypeSizeHeadline = 24.sp
+ val TypeSizeDisplay = 34.sp
+ const val TypeWeightRegular = 400
+ const val TypeWeightMedium = 600
+ const val TypeWeightBold = 700
+ const val TypeWeightHeavy = 800
+ const val MotionDurationFast = 180
+ const val MotionDurationBase = 260
+ const val MotionDurationSlow = 360
+ const val MotionDurationReduced = 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+<?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">
+ <path
+ android:fillColor="#0A0F1C"
+ android:pathData="M0,0h108v108h-108z" />
+ <path
+ android:fillColor="#3B82F6"
+ android:pathData="M34,44h40a6,6 0 0 1 6,6v20a6,6 0 0 1 -6,6h-40a6,6 0 0 1 -6,-6v-20a6,6 0 0 1 6,-6z" />
+ <path
+ android:fillColor="#E6EDF7"
+ android:pathData="M48,52h12v12h-12z" />
+</vector>
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FFFFFFFF"
+ android:pathData="M12,2a2,2 0 0 1 2,2v11.6l2.3,-2.3a1,1 0 0 1 1.4,1.4l-4,4a2,2 0 0 1 -2.8,0l-4,-4a1,1 0 1 1 1.4,-1.4l2.3,2.3v-11.6a2,2 0 0 1 2,-2z" />
+</vector>
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Vibe's own tokens (openvibe-shared): deep blue night, electric-blue glow. -->
+ <color name="ov_bg_base">#FF0A0F1C</color>
+ <color name="ov_accent">#FF3B82F6</color>
+</resources>
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">OpenVibe Link</string>
+ <string name="share_label">Send to OpenVibe</string>
+ <string name="channel_stay_connected">Stay connected</string>
+ <string name="channel_downloads">Send to OpenVibe</string>
+</resources>
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.OpenVibe" parent="Theme.AppCompat.DayNight.NoActionBar">
+ <item name="android:windowBackground">@color/ov_bg_base</item>
+ <item name="android:statusBarColor">@color/ov_bg_base</item>
+ <item name="android:navigationBarColor">@color/ov_bg_base</item>
+ <item name="android:windowLightStatusBar">false</item>
+ </style>
+
+ <style name="Theme.OpenVibe.Translucent" parent="Theme.AppCompat.DayNight.NoActionBar">
+ <item name="android:windowIsTranslucent">true</item>
+ <item name="android:windowBackground">@android:color/transparent</item>
+ <item name="android:windowNoTitle">true</item>
+ </style>
+</resources>
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Nothing of this app is backed up: the pairing and the session live in the Android Keystore, and a backup would
+ carry ciphertext a restored phone cannot read. -->
+<data-extraction-rules>
+ <cloud-backup>
+ <exclude domain="root" path="." />
+ <exclude domain="sharedpref" path="." />
+ <exclude domain="file" path="." />
+ </cloud-backup>
+ <device-transfer>
+ <exclude domain="root" path="." />
+ <exclude domain="sharedpref" path="." />
+ <exclude domain="file" path="." />
+ </device-transfer>
+</data-extraction-rules>
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+package network.openvibe.link
+
+import network.openvibe.link.approval.ApprovalCategory
+import network.openvibe.link.approval.ApprovalRules
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ApprovalRulesTest {
+ @Test
+ fun `money needs a second factor`() {
+ assertEquals(ApprovalCategory.MONEY, ApprovalRules.classify(null, "codes.task", "Buy a laptop for $1,200", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Pay the hosting invoice", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Tip the streamer 5 EUR", null))
+ }
+
+ @Test
+ fun `passwords and one-time codes need a second factor`() {
+ assertEquals(ApprovalCategory.PASSWORD, ApprovalRules.classify(null, "codes.task", "Use the password vault", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Fill in the login form", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Read the 2FA code from the authenticator", null))
+ }
+
+ @Test
+ fun `deleting needs a second factor`() {
+ assertEquals(ApprovalCategory.DELETE, ApprovalRules.classify(null, "codes.task", "Delete the old repository", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Wipe the drive", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Cancel my subscription", null))
+ }
+
+ @Test
+ fun `a send to someone new needs a second factor`() {
+ assertEquals(ApprovalCategory.NEW_RECIPIENT, ApprovalRules.classify(null, "codes.task", "Send this to a new contact", null))
+ assertTrue(ApprovalRules.needsBiometrics(null, "codes.task", "Email the report to Dana", null))
+ }
+
+ @Test
+ fun `an ordinary task is answered without a prompt`() {
+ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify(null, "codes.task", "Write a summary of the notes", null))
+ assertFalse(ApprovalRules.needsBiometrics(null, "codes.task", "Write a summary of the notes", null))
+ assertFalse(ApprovalRules.needsBiometrics(null, "codes.task", "Display the list", null))
+ }
+
+ @Test
+ fun `the service's own category wins`() {
+ assertEquals(ApprovalCategory.MONEY, ApprovalRules.classify("money", "codes.task", "Rename a file", null))
+ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify("other", "codes.task", "Delete everything", null))
+ assertFalse(ApprovalRules.needsBiometrics("other", "codes.task", "Pay the invoice", null))
+ }
+
+ @Test
+ fun `an unknown category falls back to the words`() {
+ assertEquals(ApprovalCategory.DELETE, ApprovalRules.classify("something_new", "codes.task", "Delete it", null))
+ }
+
+ @Test
+ fun `the summary counts when the title is plain`() {
+ assertEquals(
+ ApprovalCategory.MONEY,
+ ApprovalRules.classify(null, "approval", "A coding task", "It would spend 40 USD on the API"),
+ )
+ }
+
+ @Test
+ fun `nothing at all is other`() {
+ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify(null, null, null, null))
+ assertEquals(ApprovalCategory.OTHER, ApprovalRules.classify(" ", "", "", ""))
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+package network.openvibe.link
+
+import network.openvibe.link.data.PairLinks
+import network.openvibe.link.data.Pkce
+import network.openvibe.link.data.Servers
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PairLinksTest {
+ private val ours = "https://openvibe.network"
+
+ @Test
+ fun `the QR link carries the code and the pairing id`() {
+ val link = PairLinks.parse("openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR", ours)!!
+ assertEquals("ABCD-1234", link.code)
+ assertEquals("pair_01JD2C3D4E5F6G7H8J9K0MNPQR", link.pairing)
+ assertNull(link.refusedServer)
+ assertEquals(ours, link.network)
+ assertNull(PairLinks.parse("openvibe-link://pair?code=ABCD-1234&pairing=nonsense", ours)!!.pairing)
+ }
+
+ @Test
+ fun `a link with no server is ours`() {
+ assertNull(PairLinks.parse("openvibe-link://pair?code=ABCD-1234", ours)!!.refusedServer)
+ assertEquals(ours, PairLinks.parse("openvibe-link://pair?code=ABCD-1234", ours)!!.network)
+ }
+
+ @Test
+ fun `a link that names another server is refused whole, with no code`() {
+ val link = PairLinks.parse("openvibe-link://pair?code=ABCD-1234&network=https%3A%2F%2Fevil.test", ours)!!
+ assertEquals("evil.test", link.refusedServer)
+ assertNull("a code from another server is never redeemed", link.code)
+ assertNull(link.pairing)
+ // A plain http URL is not a server this app can talk to either.
+ assertEquals("http://evil.test", PairLinks.parse("openvibe-link://pair?code=ABCD-1234&network=http%3A%2F%2Fevil.test", ours)!!.refusedServer)
+ // Our own server, written differently, is still ours.
+ assertNull(PairLinks.parse("openvibe-link://pair?code=ABCD-1234&network=https%3A%2F%2FOPENVIBE.network%2F", ours)!!.refusedServer)
+ }
+
+ @Test
+ fun `another scheme is not a pairing link`() {
+ assertNull(PairLinks.parse("https://openvibe.bot/link/phone?code=ABCD-1234", ours))
+ assertNull(PairLinks.parse("openvibe-link://auth?code=ABCD-1234", ours))
+ assertNull(PairLinks.parse(null, ours))
+ }
+
+ @Test
+ fun `a scan of a bare code or a page link is read`() {
+ // The code is passed to the Go core as it was scanned; it normalizes the Crockford form.
+ assertEquals("abcd 1234", PairLinks.codeFromScan("abcd 1234", ours)!!.code)
+ assertEquals("ABCD-1234", PairLinks.codeFromScan("https://openvibe.bot/link/phone?code=ABCD-1234", ours)!!.code)
+ assertEquals("evil.test", PairLinks.codeFromScan("https://openvibe.bot/link/phone?code=ABCD-1234&network=https%3A%2F%2Fevil.test", ours)!!.refusedServer)
+ assertNull(PairLinks.codeFromScan("https://example.test/hello", ours))
+ }
+}
+
+class ServersTest {
+ @Test
+ fun `a server compares by scheme, host and path, not spelling`() {
+ assertTrue(Servers.same("https://openvibe.network", "https://openvibe.network/"))
+ assertTrue(Servers.same("https://OpenVibe.Network", "https://openvibe.network"))
+ assertFalse(Servers.same("https://evil.test", "https://openvibe.network"))
+ assertFalse("https only", Servers.same("http://openvibe.network", "https://openvibe.network"))
+ assertNull(Servers.normalize("openvibe.network"))
+ }
+}
+
+class PkceTest {
+ @Test
+ fun `the verifier and the challenge are the RFC 7636 pair`() {
+ val verifier = Pkce.verifier()
+ assertTrue(Pkce.isVerifier(verifier))
+ assertEquals(43, verifier.length)
+ // RFC 7636 appendix B: SHA-256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") in base64url.
+ assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))
+ }
+
+ @Test
+ fun `two requests get different verifiers and states`() {
+ assertNotEquals(Pkce.verifier(), Pkce.verifier())
+ assertNotEquals(Pkce.state(), Pkce.state())
+ }
+
+ @Test
+ fun `a short verifier is refused`() {
+ assertFalse(Pkce.isVerifier("short"))
+ assertFalse(Pkce.isVerifier(null))
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+package network.openvibe.link
+
+import androidx.activity.compose.LocalActivityResultRegistryOwner
+import androidx.activity.result.ActivityResultRegistry
+import androidx.activity.result.ActivityResultRegistryOwner
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.core.app.ActivityOptionsCompat
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Paparazzi
+import network.openvibe.link.ui.ActivityScreen
+import network.openvibe.link.ui.ApprovalsScreen
+import network.openvibe.link.ui.LinkTheme
+import network.openvibe.link.ui.PairScreen
+import network.openvibe.link.ui.PhoneScreen
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Every screen, drawn from the debug PreviewActivity's sample data, in dark and in light (Pixel 5). The pictures land
+ * in src/test/snapshots: `gradle :app:recordPaparazziDebug` writes them, `verifyPaparazziDebug` compares. docs/android.md
+ * shows the dark ones. A designer changes a screen, records, and looks.
+ */
+class ScreensSnapshotTest {
+ @get:Rule
+ val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5, theme = "android:Theme.Material.NoActionBar")
+
+ /** The pair screen asks for a scanner result; under Paparazzi there is no activity, so a registry that never launches. */
+ private val noResults = object : ActivityResultRegistryOwner {
+ override val activityResultRegistry = object : ActivityResultRegistry() {
+ override fun <I, O> onLaunch(requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat?) = Unit
+ }
+ }
+
+ private fun shot(dark: Boolean, content: @Composable () -> Unit) = paparazzi.snapshot {
+ CompositionLocalProvider(LocalActivityResultRegistryOwner provides noResults) {
+ LinkTheme(dark = dark) {
+ Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { content() }
+ }
+ }
+ }
+
+ @Test fun approvalsDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_APPROVALS) }
+ @Test fun approvalsLight() = shot(false) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_APPROVALS) }
+ @Test fun approvalsEmptyDark() = shot(true) { ApprovalsScreen(preview = emptyList()) }
+ @Test fun activityDark() = shot(true) { ActivityScreen(preview = PreviewActivity.SAMPLE_ACTIVITY) }
+ @Test fun activityLight() = shot(false) { ActivityScreen(preview = PreviewActivity.SAMPLE_ACTIVITY) }
+ @Test fun phoneDark() = shot(true) { PhoneScreen(previewInfo = PreviewActivity.SAMPLE_DEVICE, preview = true) }
+ @Test fun phoneLight() = shot(false) { PhoneScreen(previewInfo = PreviewActivity.SAMPLE_DEVICE, preview = true) }
+ @Test fun pairDark() = shot(true) { PairScreen(initialCode = "ABCD-1234") }
+ @Test fun pairLight() = shot(false) { PairScreen(initialCode = "ABCD-1234") }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+package network.openvibe.link
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/**
+ * The rules about secrets that a unit test can hold the sources to: the app keeps every token and the pairing behind
+ * one Keystore key (Store), and no part of it writes one to a log, a stack trace or an unencrypted preference — a
+ * crash report carries logs, so a token in a log is a token on someone else's server. The upload and the prompt are
+ * checked in ShareIntentsTest/ApprovalRulesTest; a device is needed for the flows themselves.
+ */
+class SecretsTest {
+ private val main = File("src/main/java/network/openvibe/link")
+
+ @Test
+ fun `nothing in the app logs`() {
+ val offenders = main.walkTopDown()
+ .filter { it.isFile && it.extension == "kt" }
+ .filter { file ->
+ val text = file.readText()
+ Regex("""\bLog\.[dviwe]\(""").containsMatchIn(text) || text.contains("println(")
+ }
+ .map { it.name }
+ .toList()
+ assertTrue("these files log: $offenders", offenders.isEmpty())
+ }
+
+ @Test
+ fun `secrets are written only through the Keystore cipher`() {
+ val store = File(main, "data/Store.kt").readText()
+ assertTrue("the key is the Android Keystore's", store.contains("AndroidKeyStore"))
+ assertTrue("AES-GCM seals every value", store.contains("AES/GCM/NoPadding"))
+ assertTrue("a stored secret is named through encryptedKey", store.contains("putString(encryptedKey("))
+ // No preference write of a plain name: an unencrypted token would sit in the app's data directory.
+ assertFalse("an unencrypted value is never written", Regex("""putString\("(access|refresh|pairing)"""").containsMatchIn(store))
+ assertTrue("nothing is written when the cipher fails", store.contains("catch (e: Exception)") || store.contains("try {"))
+ }
+
+ @Test
+ fun `the session is kept in the app's own storage and excluded from backups`() {
+ val manifest = File("src/main/AndroidManifest.xml").readText()
+ assertTrue(manifest.contains("android:allowBackup=\"false\""))
+ assertTrue(manifest.contains("android:fullBackupContent=\"false\""))
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+package network.openvibe.link
+
+import network.openvibe.link.share.ShareIntents
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ShareIntentsTest {
+ @Test
+ fun `one file`() {
+ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "image/jpeg", listOf("content://media/1"), null)!!
+ assertEquals("image/jpeg", share.mimeType)
+ assertEquals(listOf("content://media/1"), share.uris)
+ assertFalse(share.isMultiple)
+ assertTrue(share.hasFiles)
+ assertNull(share.text)
+ }
+
+ @Test
+ fun `several files keep their order and drop repeats`() {
+ val share = ShareIntents.parse(
+ ShareIntents.ACTION_SEND_MULTIPLE,
+ "image/*",
+ listOf("content://media/1", "content://media/2", "content://media/1"),
+ null,
+ )!!
+ assertEquals(listOf("content://media/1", "content://media/2"), share.uris)
+ assertTrue(share.isMultiple)
+ }
+
+ @Test
+ fun `a single send takes the first stream only`() {
+ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "application/pdf", listOf("content://a", "content://b"), null)!!
+ assertEquals(listOf("content://a"), share.uris)
+ assertFalse(share.isMultiple)
+ }
+
+ @Test
+ fun `text on its own is a text share`() {
+ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", emptyList(), " a note\nsecond line ")!!
+ assertEquals("text/plain", share.mimeType)
+ assertFalse(share.hasFiles)
+ assertEquals("a note\nsecond line", share.text)
+ }
+
+ @Test
+ fun `a wildcard from the sender becomes a sane type`() {
+ val share = ShareIntents.parse(ShareIntents.ACTION_SEND, "*/*", listOf("content://a"), null)!!
+ assertEquals(ShareIntents.FALLBACK_MIME, share.mimeType)
+ val text = ShareIntents.parse(ShareIntents.ACTION_SEND, "*/*", emptyList(), "hello")!!
+ assertEquals(ShareIntents.TEXT_MIME, text.mimeType)
+ }
+
+ @Test
+ fun `nothing to send is null`() {
+ assertNull(ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", emptyList(), null))
+ assertNull(ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", emptyList(), " "))
+ assertNull(ShareIntents.parse(ShareIntents.ACTION_SEND, "text/plain", listOf(" "), ""))
+ }
+
+ @Test
+ fun `another action is not a share`() {
+ assertNull(ShareIntents.parse("android.intent.action.VIEW", "text/plain", listOf("content://a"), null))
+ assertNull(ShareIntents.parse(null, null, listOf("content://a"), null))
+ }
+
+ @Test
+ fun `a pasted text gets a file name from its first line`() {
+ assertEquals("Groceries.txt", ShareIntents.nameFor("Groceries\nmilk"))
+ assertNull(ShareIntents.nameFor(" \n"))
+ assertNull(ShareIntents.nameFor(null))
+ val long = "x".repeat(200)
+ assertTrue(ShareIntents.nameFor(long)!!.endsWith("….txt"))
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+package network.openvibe.link
+
+import network.openvibe.link.share.ShareIntents
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The share sheet asks before it sends: nothing is uploaded on the intent alone, so the question says what will go and
+ * Cancel sends nothing (ShareActivity's ConfirmShare). The reading rules themselves are in ShareIntentsTest.
+ */
+class ShareQuestionTest {
+ private fun request(action: String, mime: String?, uris: List<String>, text: String? = null) =
+ ShareIntents.parse(action, mime, uris, text)
+
+ @Test
+ fun `one file asks about one file`() {
+ val r = request(ShareIntents.ACTION_SEND, "image/png", listOf("content://a/1"))!!
+ assertEquals("Send this to your Download?", r.question)
+ }
+
+ @Test
+ fun `several files say how many`() {
+ val r = request(ShareIntents.ACTION_SEND_MULTIPLE, "image/*", listOf("content://a/1", "content://a/2", "content://a/3"))!!
+ assertEquals("Send these 3 files to your Download?", r.question)
+ }
+
+ @Test
+ fun `pasted text asks about text`() {
+ val r = request(ShareIntents.ACTION_SEND, "text/plain", emptyList(), "hello")!!
+ assertEquals("Send this text to your Download?", r.question)
+ }
+
+ @Test
+ fun `an empty share has no question because there is nothing to ask`() {
+ assertNull(request(ShareIntents.ACTION_SEND, "text/plain", emptyList(), " "))
+ assertNull(request("android.intent.action.VIEW", "text/plain", listOf("content://a/1")))
+ assertNotNull(request(ShareIntents.ACTION_SEND, "*/*", listOf("content://a/1")))
+ assertTrue(request(ShareIntents.ACTION_SEND, "image/png", listOf("content://a/1"))!!.hasFiles)
+ }
+}
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsEmptyDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsEmptyDark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_phoneDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_phoneDark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_phoneLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_phoneLight.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+// Versions are pinned here and in android/gradle/wrapper/gradle-wrapper.properties; the CI uses the same pair.
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("app.cash.paparazzi") version "1.3.5" apply false
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+v4.135.0
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
new file mode 100755
new file mode 100755
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+#!/bin/sh
+# Bind the Node's Go core (mobile/mobile.go, the pairing and the link) into an Android library the app links against.
+# It is the same code the desktop Node runs, so pairing, token refresh and reconnects behave identically.
+#
+# scripts/build-core.sh # write android/app/libs/core.aar
+#
+# Needs Go, the Android SDK and NDK (ANDROID_HOME or ANDROID_NDK_HOME), and writes nothing into the repository that is
+# committed: android/app/libs/ is ignored.
+#
+# The bind runs against a temporary module that replaces the Node with this checkout, so the Node's own go.mod (and the
+# desktop binaries built from it) never gains the bind tool or its newer dependencies.
+set -eu
+
+unset CDPATH
+android=$(cd -- "$(dirname -- "$0")/.." && pwd)
+repo=$(cd -- "$android/.." && pwd)
+
+# Pinned: the bind tool that produced the AAR, so a rebuild is the same build.
+GOMOBILE=v0.0.0-20261011034528-197770444867
+
+if [ -z "${ANDROID_NDK_HOME:-}" ]; then
+ home=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
+ [ -n "$home" ] || { echo "set ANDROID_HOME (or ANDROID_NDK_HOME) to the Android SDK" >&2; exit 1; }
+ for candidate in "$home"/ndk/*; do [ -d "$candidate" ] && ndk=$candidate; done
+ [ -n "$ndk" ] || { echo "no NDK under $home/ndk: install one with sdkmanager 'ndk;<version>'" >&2; exit 1; }
+ ANDROID_NDK_HOME=$ndk
+ export ANDROID_NDK_HOME
+fi
+
+echo "gomobile $GOMOBILE, NDK $ANDROID_NDK_HOME"
+GOBIN=$(go env GOPATH)/bin
+GOBIN=$GOBIN go install "golang.org/x/mobile/cmd/gomobile@$GOMOBILE"
+GOBIN=$GOBIN go install "golang.org/x/mobile/cmd/gobind@$GOMOBILE"
+"$GOBIN/gomobile" init
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+# A module of its own: the Node's go.mod, with a name of its own (a module cannot require itself) and this checkout
+# in place of the published module.
+sed 's/^module .*/module openvibe-link\/core/' "$repo/go.mod" > "$tmp/go.mod"
+cp "$repo/go.sum" "$tmp/"
+printf '\nreplace github.com/OpenVibers/OpenVibe.Node => %s\n' "$repo" >> "$tmp/go.mod"
+
+(
+ cd "$tmp"
+ export GOFLAGS="-mod=mod -buildvcs=false"
+ # go get, not go mod tidy: tidy would drop a module the bind uses but nothing imports.
+ go get github.com/OpenVibers/[email protected] >/dev/null
+ go get -tool "golang.org/x/mobile/cmd/gobind@$GOMOBILE" >/dev/null
+ mkdir -p "$android/app/libs"
+ "$GOBIN/gomobile" bind -target=android/arm64,android/arm -androidapi 26 \
+ -o "$android/app/libs/core.aar" github.com/OpenVibers/OpenVibe.Node/mobile
+)
+
+echo "wrote android/app/libs/core.aar"
new file mode 100755
new file mode 100755
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+#!/bin/sh
+# Generate the phone's design system from the pinned openvibe-shared release, so the app wears the same theme and kit
+# as every OpenVibe site. Run it after a Design release; never edit the generated files.
+#
+# scripts/gen-design.sh # rewrite app/src/main/java/network/openvibe/{theme,kit}
+# scripts/gen-design.sh --check # refuse if the generated files differ from what is committed
+#
+# It downloads the release tarball (no npm registry, no token), runs the release's own scripts/native-check.js, and
+# copies exactly the Kotlin files it writes. The pinned version is android/design-version.txt.
+set -eu
+
+unset CDPATH
+android=$(cd -- "$(dirname -- "$0")/.." && pwd)
+version=$(tr -d ' \n' < "$android/design-version.txt")
+target="$android/app/src/main/java/network/openvibe"
+check=no
+[ "${1:-}" = "--check" ] && check=yes
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+echo "openvibe-shared $version"
+curl -fsSL "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/$version" | tar -xz -C "$tmp"
+src=$(find "$tmp" -maxdepth 1 -type d -name 'OpenVibe.Shared-*' | head -n 1)
+[ -n "$src" ] || { echo "the release tarball had no OpenVibe.Shared-* directory" >&2; exit 1; }
+
+node "$src/scripts/native-check.js" --out "$tmp/native"
+
+mkdir -p "$target/theme" "$target/kit"
+cp "$tmp/native/kotlin/VibeTheme.kt" "$target/theme/VibeTheme.kt"
+cp "$tmp/native/kotlin/OvKit.kt" "$target/kit/OvKit.kt"
+cp "$tmp/native/kotlin/OvKitStubs.kt" "$target/kit/OvKitStubs.kt"
+cp "$tmp/native/kotlin/KitRenderer.kt" "$target/kit/KitRenderer.kt"
+
+echo "wrote $target/theme/VibeTheme.kt and $target/kit/{OvKit,OvKitStubs,KitRenderer}.kt"
+
+if [ "$check" = yes ]; then
+ if ! git -C "$android" diff --quiet -- app/src/main/java/network/openvibe; then
+ echo "the generated design files are stale: run scripts/gen-design.sh and commit the result" >&2
+ git -C "$android" --no-pager diff --stat -- app/src/main/java/network/openvibe >&2
+ exit 1
+ fi
+ echo "the generated design files match $version"
+fi
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Screenshots of the four screens, taken on an emulator in CI. The screens that need an account are drawn from the
+# debug-only PreviewActivity with sample data; the Pair screen is the real one.
+#
+# scripts/screenshots.sh [outdir]
+set -eu
+
+out=${1:-shots}
+mkdir -p "$out"
+
+adb wait-for-device
+adb shell wm dismiss-keyguard || true
+adb shell settings put global window_animation_scale 0 || true
+adb shell settings put global transition_animation_scale 0 || true
+adb shell settings put global animator_duration_scale 0 || true
+
+apk=$(find android/app/build/outputs/apk/debug -name "*.apk" | head -n 1)
+adb install -r "$apk"
+
+for screen in pair approvals activity phone; do
+ adb shell am force-stop network.openvibe.link || true
+ adb shell am start -W -n network.openvibe.link/.PreviewActivity --es screen "$screen" >/dev/null
+ sleep 2
+ adb exec-out screencap -p > "$out/$screen.png"
+ echo "shot $screen"
+done
+
+# The launcher entry, as a person sees it first.
+adb shell am force-stop network.openvibe.link || true
+adb shell am start -W -n network.openvibe.link/.MainActivity >/dev/null
+sleep 2
+adb exec-out screencap -p > "$out/app.png"
+echo "shot app"
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "OpenVibeLink"
+include(":app")
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+# Link on your phone (Android)
+
+OpenVibe Link for Android puts your account in your pocket: **Approvals** for what your agents asked while you were
+away, **Activity** (the Dock's timeline), your phone as a **device** on your account, and **Send to OpenVibe** from any
+app's share sheet.
+
+This is v0 of the phone build (plan 2026-11-07 §13.2). It has no phone automation: no AccessibilityService, no
+MediaProjection, no reading the screen and no tapping for anyone. The only thing it does in the background is hold one
+outbound connection, and only when you turn **Stay connected** on.
+
+## Install
+
+1. Download `openvibe-link-v<version>.apk` from the repository's Releases.
+2. On the phone, allow installing from that source when Android asks.
+3. Open **OpenVibe Link**.
+
+The APK is signed with the project's release key. A debug APK (`app-debug.apk`) is attached to CI runs and can be
+installed the same way; Android allows it beside nothing else (the package is `network.openvibe.link`).
+
+## Pair the phone
+
+1. On a computer signed in as you, open **https://openvibe.bot/link/phone**.
+2. The page shows a code and a QR. The code works once and lasts 15 minutes.
+3. In the app, type the code or tap **Scan the QR instead**.
+
+Pairing is the Node's own: the code is redeemed on OpenVibe.Network, the phone gets a node credential, and it is bound
+on OpenVibe.Bot as a device of kind **phone**. The credential never leaves the phone in the clear: it is encrypted with
+a key the Android Keystore holds, and nothing on the phone is backed up.
+
+## The four screens
+
+- **Pair**: the code field and the scanner. A QR encodes `openvibe-link://pair?code=…&pairing=…`, which opens the app
+ with the code filled in even from the phone's camera app. The app talks only to **https://openvibe.network** (a build
+ constant, never a field): a QR or link that names another server is refused and says which.
+- **Approvals**: Actor's pending approvals for you. Approve or decline. **Money, passwords, deletions and a send to a
+ new recipient ask for a fingerprint, a face or the device credential first** (BIOMETRIC_STRONG, with the device
+ credential behind it on Android 11+), and the answer is only sent after it succeeds.
+- **Activity**: the Dock's timeline, read-only.
+- **This phone**: what the phone is paired as, **Stay connected** (a foreground service with a notification you can
+ always see and stop), sign-in, and **Unpair**.
+
+Approvals and Activity need your OpenVibe account as well as the pairing: tap **Sign in to OpenVibe**. That is a public
+OAuth client (`link`) with PKCE S256 and no secret in the APK. The sign-in returns to
+`https://openvibe.network/link/auth`, an **Android App Link** claimed for this package (Network publishes
+`/.well-known/assetlinks.json` with the release certificate's SHA-256), so no other app can answer the redirect; the
+chooser always shows what the app does, and its token is your own session with your non-staff role.
+
+Until the owner's release key exists (`LINK_ANDROID_CERT_SHA256` on Network), Android verifies no app for that link and
+the client gets Network's read-only Dock token instead of a session: the app still pairs, and signing in is what waits
+for the key. A debug build returns on `openvibe-link://auth` and always gets the Dock token.
+
+The tokens and the pairing are AES-GCM-sealed with a key the Android Keystore makes and never exports, nothing is
+logged, and the app's data is excluded from Android backups.
+
+### The screens
+
+Rendered from the Composables by Paparazzi (Pixel 5, dark; every screen is also recorded in light). The pictures come
+from `app/src/test/java/network/openvibe/link/ScreensSnapshotTest.kt` and the PreviewActivity's sample data:
+`gradle :app:recordPaparazziDebug` writes them to `app/src/test/snapshots/images/`, and `verifyPaparazziDebug` fails
+when a screen changes without a new recording.
+
+The look (Opus, 11-07) is OpenVibe's: Vibe's tokens from openvibe-shared's native export, with Material 3's container
+roles mapped to Vibe surfaces (`ui/Theme.kt`; Shared 4.140.0 does the same in the export), and a small kit in
+`ui/Kit.kt`:
+- a page with its title and one line;
+- cards on the raised surface with a hairline;
+- chips whose tone says how much a thing matters: money amber, deleting red, passwords violet, a new recipient blue;
+- still status dots;
+- still skeletons while the first answer loads (nothing spins);
+- empty states that say what will appear.
+
+| Pair | Approvals |
+|---|---|
+|  |  |
+
+| Activity | This phone |
+|---|---|
+|  |  |
+
+The CI's emulator job (`screenshots`) also takes the four on a real emulator, non-blocking beside the build. The app
+has a debug-only `PreviewActivity` that draws one screen with the same sample data without an account:
+`adb shell am start -n network.openvibe.link/.PreviewActivity --es screen approvals`.
+
+## Send to OpenVibe
+
+Share a file or a photo from any app and pick **Send to OpenVibe**. The sheet asks first — **Send** starts the upload,
+**Cancel** sends nothing. It goes to your **Download** drive
+(openvibe.download) through the drive's own upload (the same three steps the web page makes: start, parts, complete),
+with progress in a notification. Nothing is re-encoded. A shared text becomes a small `.txt` named after its first
+line.
+
+## Build it
+
+The app is in `android/`; it shares the Node's Go core through `gomobile bind`, so pairing, token refresh and
+reconnects are the same code the desktop Node runs. A debug build may be pointed at another server with
+`./gradlew :app:assembleDebug -Pnetwork.url=https://staging.example`; that build shows a **Debug server** banner on every
+screen, and a release build always carries the official one.
+
+```sh
+# 1. Generate the design system from the pinned openvibe-shared release (never hand-edit these files).
+sh android/scripts/gen-design.sh
+
+# 2. Bind the Go core into android/app/libs/core.aar (needs an Android NDK).
+ANDROID_HOME=~/Android/Sdk sh android/scripts/build-core.sh
+
+# 3. Build and test.
+gradle -p android :app:testDebugUnitTest
+gradle -p android :app:assembleDebug
+```
+
+Versions are pinned: Gradle 8.11.1 (`android/gradle/wrapper/gradle-wrapper.properties` and the CI), Android Gradle
+Plugin 8.7.3 and Kotlin 2.0.21 (`android/build.gradle.kts`), gomobile and the openvibe-shared release in the two
+scripts. `android/design-version.txt` names the shared release; the CI refuses a stale generated theme.
+
+CI (`.github/workflows/android.yml`) builds the core, runs the unit tests, builds the debug APK and attaches a signed
+release APK on a tag when the repository has the signing key (`ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`,
+`ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`). With no key it says so and skips the signed build; it never generates one.
+A second, non-blocking job takes screenshots on an emulator.
+
+## Tests
+
+- `android/app/src/test`: the approval categories that need biometrics (`ApprovalRulesTest`), the share-intent
+ parsing (`ShareIntentsTest`), the pairing links and PKCE (`PairLinksTest`).
+- `go test ./mobile`: pairing against a fake Network and Bot.
+- The rest of the Go tests are unchanged.
+
+## What v0 does not do
+
+- No phone automation (plan §13.2's opt-in Accessibility Service and MediaProjection are a later, separate build).
+- No Google Play, no Firebase, no third-party account.
+- No iOS yet.
+- Activity is read-only; answering happens in Approvals.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/activity.png differ
Binary files /dev/null and b/docs/screenshots/activity.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/approvals.png differ
Binary files /dev/null and b/docs/screenshots/approvals.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/pair.png differ
Binary files /dev/null and b/docs/screenshots/pair.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/phone.png differ
Binary files /dev/null and b/docs/screenshots/phone.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
+// Package mobile is the Link phone's Go core. It is the same pairing and connection code the Node runs
+// (internal/link), bound with `gomobile bind` into an AAR the Android app links against, so the phone pairs and
+// stays connected exactly as `openvibe-node pair --network … --pairing … --code …` does.
+//
+// The core keeps no secret on disk: Pair returns the credentials as JSON for the app to encrypt with a key in the
+// Android Keystore, and every later call takes that JSON back. Nothing here logs a credential or a token: the link's
+// logger is discarded and the status strings carry only what the person needs to read.
+//
+// The exported surface is deliberately small and gomobile-friendly: strings, errors and one callback interface.
+package mobile
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
+ "github.com/OpenVibers/OpenVibe.Node/internal/link"
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+)
+
+// Version is what the phone tells the services it is.
+const Version = "0.1.0"
+
+// DeviceKind is how the phone shows up on the person's account (OpenVibe.Bot's devices).
+const DeviceKind = "phone"
+
+const userAgent = "openvibe-link/" + Version
+
+// pairing is the credential bundle Pair and Bind return as JSON. The field names match the ones the CLI stores, so
+// a credential file from a phone could be read by the Node and the other way round.
+type pairing struct {
+ Principal string `json:"principal"`
+ NodeCredential string `json:"node_credential"`
+ Network string `json:"network"`
+ DeviceID string `json:"device_id,omitempty"`
+ RobotID string `json:"robot_id,omitempty"`
+ Server string `json:"server"`
+ PairedFor *protocol.PairedFor `json:"paired_for,omitempty"`
+ PairedAt time.Time `json:"paired_at"`
+}
+
+// Listener hears the link's state. Called from the link's goroutines, one at a time.
+type Listener interface {
+ // OnState receives JSON: {"state":"connected"|"offline"|"stopped"|"revoked","detail":"…"}.
+ OnState(event string)
+}
+
+// NormalizeCode accepts what a person types (abcd 1234) and returns the Crockford form ABCD1234.
+func NormalizeCode(code string) (string, error) {
+ return link.NormalizeCode(code)
+}
+
+// Pair redeems a pairing code on OpenVibe.Network and binds the phone as a device on OpenVibe.Bot. It returns the
+// credential bundle as JSON. A bind that fails is not fatal: Start binds again, exactly as the Node does.
+func Pair(networkURL, botURL, code, name, pairingID string) (string, error) {
+ code, err := link.NormalizeCode(code)
+ if err != nil {
+ return "", err
+ }
+ // The QR on openvibe.bot/link/phone carries the pair_… id, so a wrong code is counted against that pairing's code,
+ // exactly as `openvibe-node pair --network … --pairing … --code …` does. A typed code may omit it.
+ if pairingID != "" && !link.PairingIDRe.MatchString(pairingID) {
+ return "", errors.New("the pairing id is not valid; scan the QR again for a fresh code")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ c, err := link.PairNetwork(ctx, nil, networkURL, protocol.NodePairingRequest{Code: code, Pairing: pairingID, Name: name}, userAgent)
+ if err != nil {
+ return "", err
+ }
+ c.Server = trimSlash(botURL)
+ if c.Server == "" {
+ c.Server = "https://openvibe.bot"
+ }
+ p := pairingOf(c)
+ if bound, err := link.Bind(ctx, nil, c, link.NewTokenSource(c, nil, userAgent), userAgent); err == nil {
+ p = pairingOf(bound)
+ } else if errors.Is(err, link.ErrNodeRevoked) {
+ return "", err
+ }
+ return marshalPairing(p)
+}
+
+// Bind binds a paired phone whose device record is missing (the bind at pairing failed). It returns the bundle.
+func Bind(credsJSON string) (string, error) {
+ c, err := parsePairing(credsJSON)
+ if err != nil {
+ return "", err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ bound, err := link.Bind(ctx, nil, c, link.NewTokenSource(c, nil, userAgent), userAgent)
+ if err != nil {
+ return "", err
+ }
+ return marshalPairing(pairingOf(bound))
+}
+
+// Info is what the "This phone" screen shows, as JSON: the device, its account and its binding state.
+func Info(credsJSON string) (string, error) {
+ p, err := parsePairingJSON(credsJSON)
+ if err != nil {
+ return "", err
+ }
+ out, _ := json.Marshal(struct {
+ DeviceID string `json:"device_id"`
+ Principal string `json:"principal"`
+ Server string `json:"server"`
+ Network string `json:"network"`
+ Kind string `json:"kind"`
+ Bound bool `json:"bound"`
+ }{p.DeviceID, p.Principal, p.Server, p.Network, DeviceKind, p.DeviceID != ""})
+ return string(out), nil
+}
+
+// Link keeps the phone's one outbound connection to OpenVibe.Bot alive while it runs.
+type Link struct {
+ mu sync.Mutex
+ h *handler
+ l *link.Link
+ cancel context.CancelFunc
+ done chan struct{}
+}
+
+// NewLink prepares a connection from a credential bundle. Start dials; nothing is sent before a pairing exists.
+func NewLink(credsJSON string) (*Link, error) {
+ c, err := parsePairing(credsJSON)
+ if err != nil {
+ return nil, err
+ }
+ u := c.DeviceURL
+ if u == "" {
+ if u, err = link.DeviceURL(c.Server); err != nil {
+ return nil, err
+ }
+ }
+ h := &handler{}
+ if c.NetworkPaired() {
+ l := link.New(link.Options{
+ URL: u, Tokens: link.NewTokenSource(c, nil, userAgent), UserAgent: userAgent,
+ Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ }, h)
+ return &Link{h: h, l: l}, nil
+ }
+ l := link.New(link.Options{
+ URL: u, Credential: c.Credential, UserAgent: userAgent,
+ Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ }, h)
+ return &Link{h: h, l: l}, nil
+}
+
+// Start runs the link until Stop (or a revocation that retrying cannot fix). It returns immediately.
+func (l *Link) Start(listener Listener) error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.cancel != nil {
+ return nil
+ }
+ l.h.setListener(listener)
+ ctx, cancel := context.WithCancel(context.Background())
+ l.cancel, l.done = cancel, make(chan struct{})
+ go func() {
+ defer close(l.done)
+ if err := l.l.Run(ctx); err != nil {
+ emit(l.h.listener(), "revoked", err.Error())
+ } else {
+ emit(l.h.listener(), "stopped", "")
+ }
+ }()
+ return nil
+}
+
+// Stop closes the connection. It is safe to call when the link is not running.
+func (l *Link) Stop() {
+ l.mu.Lock()
+ cancel, done := l.cancel, l.done
+ l.cancel, l.done = nil, nil
+ l.mu.Unlock()
+ if cancel == nil {
+ return
+ }
+ cancel()
+ <-done
+}
+
+// Status is the connection's state as JSON.
+func (l *Link) Status() string {
+ st := l.l.Stats()
+ out, _ := json.Marshal(struct {
+ Connected bool `json:"connected"`
+ Reconnects int64 `json:"reconnects"`
+ LastError string `json:"last_error"`
+ }{l.l.Connected(), st.Reconnects, st.LastError})
+ return string(out)
+}
+
+// handler is the link's callbacks, passed on to the app's listener.
+type handler struct {
+ mu sync.Mutex
+ lis Listener
+}
+
+func (h *handler) setListener(l Listener) { h.mu.Lock(); h.lis = l; h.mu.Unlock() }
+
+func (h *handler) listener() Listener { h.mu.Lock(); defer h.mu.Unlock(); return h.lis }
+
+func (h *handler) Connected() { emit(h.listener(), "connected", "") }
+
+func (h *handler) Frame(protocol.Frame) {}
+
+func (h *handler) Disconnected(err error) {
+ detail := ""
+ if err != nil {
+ detail = err.Error()
+ }
+ emit(h.listener(), "offline", detail)
+}
+
+func emit(l Listener, state, detail string) {
+ if l == nil {
+ return
+ }
+ b, _ := json.Marshal(struct {
+ State string `json:"state"`
+ Detail string `json:"detail,omitempty"`
+ }{state, detail})
+ l.OnState(string(b))
+}
+
+func pairingOf(c *credentials.Credentials) pairing {
+ return pairing{
+ Principal: c.Principal, NodeCredential: c.NodeCredential.Reveal(), Network: c.Network,
+ DeviceID: c.DeviceID, RobotID: c.RobotID, Server: c.Server, PairedFor: c.PairedFor, PairedAt: c.PairedAt,
+ }
+}
+
+func marshalPairing(p pairing) (string, error) {
+ b, err := json.Marshal(p)
+ return string(b), err
+}
+
+func parsePairingJSON(s string) (pairing, error) {
+ var p pairing
+ if err := json.Unmarshal([]byte(s), &p); err != nil {
+ return p, errors.New("the pairing is not valid")
+ }
+ return p, nil
+}
+
+func parsePairing(s string) (*credentials.Credentials, error) {
+ p, err := parsePairingJSON(s)
+ if err != nil {
+ return nil, err
+ }
+ if p.Principal == "" || p.NodeCredential == "" || p.Network == "" {
+ return nil, errors.New("this phone is not paired")
+ }
+ c := &credentials.Credentials{
+ Principal: p.Principal, NodeCredential: credentials.NewSecret(p.NodeCredential), Network: trimSlash(p.Network),
+ DeviceID: p.DeviceID, RobotID: p.RobotID, Server: trimSlash(p.Server), PairedFor: p.PairedFor, PairedAt: p.PairedAt,
+ }
+ if c.Server == "" {
+ c.Server = "https://openvibe.bot"
+ }
+ return c, nil
+}
+
+func trimSlash(s string) string {
+ for len(s) > 0 && s[len(s)-1] == '/' {
+ s = s[:len(s)-1]
+ }
+ return s
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+package mobile
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// fakeNetwork and fakeBot are the two services a phone pairs through, reduced to the answers Pair reads.
+func fakeNetwork(t *testing.T, principal, credential, ref string) (*httptest.Server, *[]string) {
+ var codes []string
+ n := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/node-pairing":
+ var body struct {
+ Code string `json:"code"`
+ Pairing string `json:"pairing"`
+ Name string `json:"name"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ codes = append(codes, body.Code)
+ if body.Pairing != "" && body.Pairing != "pair_01JD2C3D4E5F6G7H8J9K0MNPQR" {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _, _ = w.Write([]byte(`{"code":"registry.invalid_pairing_request","detail":"bad pairing id"}`))
+ return
+ }
+ if body.Code != "ABCD2345" {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"code":"registry.pairing_code_invalid","detail":"that is not the pairing code"}`))
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "principal": principal, "credential": credential,
+ "paired_for": map[string]string{"service": "bot", "ref": ref},
+ })
+ case "/oauth/token":
+ _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "node-token", "expires_in": 300})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(n.Close)
+ return n, &codes
+}
+
+func fakeBot(t *testing.T) *httptest.Server {
+ b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/devices/bind" || r.Header.Get("Authorization") != "Bearer node-token" {
+ http.Error(w, "no", http.StatusUnauthorized)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"device_id": "dev_phone"})
+ }))
+ t.Cleanup(b.Close)
+ return b
+}
+
+func TestPairBindsThePhone(t *testing.T) {
+ net, codes := fakeNetwork(t, "nod_01234567890123456789012345", "node-secret", "phone:usr_01")
+ bot := fakeBot(t)
+ creds, err := Pair(net.URL, bot.URL, "abcd-2345", "Phone", "")
+ if err != nil {
+ t.Fatalf("Pair: %v", err)
+ }
+ if len(*codes) != 1 || (*codes)[0] != "ABCD2345" {
+ t.Fatalf("the code sent was %v, want the normalized ABCD2345", *codes)
+ }
+ var p pairing
+ if err := json.Unmarshal([]byte(creds), &p); err != nil {
+ t.Fatalf("Pair returned no JSON: %v", err)
+ }
+ if p.DeviceID != "dev_phone" || p.Principal != "nod_01234567890123456789012345" || p.NodeCredential != "node-secret" {
+ t.Fatalf("pairing = %+v", p)
+ }
+ if p.Server != bot.URL || p.Network != net.URL {
+ t.Fatalf("origins = %q %q", p.Server, p.Network)
+ }
+ if p.PairedFor == nil || p.PairedFor.Service != "bot" {
+ t.Fatalf("paired_for = %+v", p.PairedFor)
+ }
+}
+
+func TestPairRefusesAWrongCode(t *testing.T) {
+ net, _ := fakeNetwork(t, "nod_01234567890123456789012345", "node-secret", "phone:usr_01")
+ bot := fakeBot(t)
+ _, err := Pair(net.URL, bot.URL, "ZZZZ-9999", "Phone", "")
+ if err == nil || !strings.Contains(err.Error(), "pairing code") {
+ t.Fatalf("a wrong code gave %v", err)
+ }
+}
+
+func TestPairRejectsAMalformedCode(t *testing.T) {
+ if _, err := Pair("https://openvibe.network", "https://openvibe.bot", "nope", "Phone", ""); err == nil {
+ t.Fatal("a malformed code was accepted")
+ }
+}
+
+func TestInfoNamesThePhone(t *testing.T) {
+ creds := `{"principal":"nod_01234567890123456789012345","node_credential":"s","network":"https://openvibe.network","device_id":"dev_phone","server":"https://openvibe.bot"}`
+ got, err := Info(creds)
+ if err != nil {
+ t.Fatalf("Info: %v", err)
+ }
+ var out struct {
+ DeviceID string `json:"device_id"`
+ Kind string `json:"kind"`
+ Bound bool `json:"bound"`
+ }
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.DeviceID != "dev_phone" || out.Kind != "phone" || !out.Bound {
+ t.Fatalf("info = %+v", out)
+ }
+}
+
+func TestNewLinkNeedsAPairing(t *testing.T) {
+ if _, err := NewLink(`{}`); err == nil {
+ t.Fatal("an empty pairing made a link")
+ }
+ if _, err := NewLink(`not json`); err == nil {
+ t.Fatal("invalid JSON made a link")
+ }
+}
+
+func TestNewLinkUsesTheBotSocket(t *testing.T) {
+ l, err := NewLink(`{"principal":"nod_01234567890123456789012345","node_credential":"s","network":"https://openvibe.network","server":"https://openvibe.bot"}`)
+ if err != nil {
+ t.Fatalf("NewLink: %v", err)
+ }
+ l.Stop() // Stop before Start is harmless.
+ if got := l.Status(); !strings.Contains(got, "connected") {
+ t.Fatalf("status = %s", got)
+ }
+}
+
+func TestPairCarriesThePairingIDFromTheQR(t *testing.T) {
+ net, _ := fakeNetwork(t, "nod_01234567890123456789012345", "node-secret", "phone:usr_01")
+ bot := fakeBot(t)
+ if _, err := Pair(net.URL, bot.URL, "abcd-2345", "Phone", "pair_01JD2C3D4E5F6G7H8J9K0MNPQR"); err != nil {
+ t.Fatalf("Pair with a pairing id: %v", err)
+ }
+ if _, err := Pair(net.URL, bot.URL, "abcd-2345", "Phone", "not-a-pairing"); err == nil {
+ t.Fatal("a malformed pairing id was accepted")
+ }
+}