openvibers/openvibe.link Commits
Link: answer HO12 camera and location requests on the phone (#138)
Changes
@@@ -12,7 +12,7 @@ permissions:jobs:apk:name: Go core, unit tests and the APK-- runs-on: ubuntu-latest++ runs-on: ubuntu-24.04permissions:contents: write # the APK is attached to the release on a tagsteps:@@@ -21,15 +21,37 @@ jobs:with:distribution: temurinjava-version: "17"-- - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0-- with:-- go-version-file: go.mod++ - name: Install pinned Go++ run: |++ curl -fL --retry 3 https://go.dev/dl/go1.24.7.linux-amd64.tar.gz -o "$RUNNER_TEMP/go1.24.7.linux-amd64.tar.gz"++ echo 'da18191ddb7db8a9339816f3e2b54bdded8047cdc2a5d67059478f8d1595c43f '"$RUNNER_TEMP/go1.24.7.linux-amd64.tar.gz" | sha256sum -c -++ tar -xzf "$RUNNER_TEMP/go1.24.7.linux-amd64.tar.gz" -C "$RUNNER_TEMP"++ echo "$RUNNER_TEMP/go/bin" >> "$GITHUB_PATH"++ echo "$("$RUNNER_TEMP/go/bin/go" env GOPATH)/bin" >> "$GITHUB_PATH"- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0with:node-version: "20"-- - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0-- with:-- gradle-version: "8.11.1"++ - name: Install pinned Gradle++ run: |++ curl -fL --retry 3 -H 'Accept: application/octet-stream' \++ https://api.github.com/repos/gradle/gradle-distributions/releases/assets/207876967 \++ -o "$RUNNER_TEMP/gradle-8.11.1-bin.zip"++ echo 'f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 '"$RUNNER_TEMP/gradle-8.11.1-bin.zip" | sha256sum -c -++ unzip -q "$RUNNER_TEMP/gradle-8.11.1-bin.zip" -d "$RUNNER_TEMP"++ echo "$RUNNER_TEMP/gradle-8.11.1/bin" >> "$GITHUB_PATH"++++ - name: Cache pinned Kotlin compiler++ run: |++ artifact="$HOME/.m2/repository/org/jetbrains/kotlin/kotlin-compiler-embeddable/2.0.21"++ mkdir -p "$artifact"++ curl -fL --retry 3 -H 'Accept: application/octet-stream' \++ https://api.github.com/repos/JetBrains/kotlin/releases/assets/587823233 \++ -o "$artifact/kotlin-compiler-embeddable-2.0.21.jar"++ echo '9fa8cdd1de0dccffe154c997d423ec6b5f53cd6d9177e3a77a9b0de03fb1bc81 '"$artifact/kotlin-compiler-embeddable-2.0.21.jar" | sha256sum -c -++ curl -fL --retry 3 \++ https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-compiler-embeddable/2.0.21/kotlin-compiler-embeddable-2.0.21.pom \++ -o "$artifact/kotlin-compiler-embeddable-2.0.21.pom"++ echo 'bd4656a46ec418252e5bc5e1c20eb2029fb2a8e0e3cc94eeddfac7e87c8cd5b6 '"$artifact/kotlin-compiler-embeddable-2.0.21.pom" | sha256sum -c -- name: Android NDK (gomobile needs one)run: |@@@ -76,7 +76,7 @@ jobs:build:name: cross-compile-- runs-on: ubuntu-latest++ runs-on: ubuntu-24.04steps:- uses: actions/checkout@v4- uses: actions/setup-go@v5@@@ -203,7 +203,7 @@ jobs:shell:name: shellcheck-- runs-on: ubuntu-latest++ runs-on: ubuntu-24.04steps:- uses: actions/checkout@v4- run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-plan-logins.sh scripts/test-install-claim.sh@@@ -216,14 +216,21 @@ jobs:esp32-conformance:name: esp32 protocol conformance (desktop)-- runs-on: ubuntu-latest++ runs-on: ubuntu-24.04steps:- uses: actions/checkout@v4++ - name: Cache pinned ArduinoJson header++ run: |++ mkdir -p esp32/test/.deps++ curl -fL --retry 3 -H 'Accept: application/octet-stream' \++ https://api.github.com/repos/bblanchon/ArduinoJson/releases/assets/265662161 \++ -o esp32/test/.deps/ArduinoJson.h++ echo 'a05ac98f4481d2398c103ca5ffcce8e2fcd7fa2fa1d8d9f38d5757454f448d97 esp32/test/.deps/ArduinoJson.h' | sha256sum -c -- run: sh esp32/test/run.shesp32-compile:name: esp32 compile (arduino-cli)-- runs-on: ubuntu-latest++ runs-on: ubuntu-24.04steps:- uses: actions/checkout@v4- uses: arduino/setup-arduino-cli@v2@@@ -232,6 +239,24 @@ jobs:arduino-cli config initarduino-cli config add board_manager.additional_urls https://espressif.github.io/arduino-esp32/package_esp32_index.jsonarduino-cli core update-index++ cache="$HOME/.arduino15/staging/packages"++ mkdir -p "$cache"++ stage_asset() {++ file=$1 repo=$2 asset=$3 checksum=$4++ curl -fLsS --retry 3 -H 'Accept: application/octet-stream' \++ "https://api.github.com/repos/$repo/releases/assets/$asset" -o "$cache/$file"++ printf '%s %s\n' "$checksum" "$cache/$file" | sha256sum -c -++ }++ stage_asset esp32-arduino-libs-idf-release_v5.3-cfea4f7c-v1.zip espressif/esp32-arduino-lib-builder 218373742 1099291229a9be453c771c5867b8487a0266db9246b672417337b8f511d7f820++ stage_asset xtensa-esp-elf-13.2.0_20240530-x86_64-linux-gnu.tar.gz espressif/crosstool-NG 171009396 bce77e8480701d5a90545369d1b5848f6048eb39c0022d2446d1e33a8e127490++ stage_asset xtensa-esp-elf-gdb-14.2_20240403-x86_64-linux-gnu.tar.gz espressif/binutils-gdb 160240516 9d68472d4cba5cf8c2b79d94f86f92c828e76a632bd1e6be5e7706e5b304d36e++ stage_asset riscv32-esp-elf-13.2.0_20240530-x86_64-linux-gnu.tar.gz espressif/crosstool-NG 171009184 e7fbfffbb19dcd3764a9848a141bf44e19ad0b48e0bd1515912345c26fe52fba++ stage_asset riscv32-esp-elf-gdb-14.2_20240403-x86_64-linux-gnu.tar.gz espressif/binutils-gdb 160240334 ce004bc0bbd71b246800d2d13b239218b272a38bd528e316f21f1af2db8a4b13++ stage_asset openocd-esp32-linux-amd64-0.12.0-esp32-20241016.tar.gz espressif/openocd-esp32 200012418 e82b0f036dc99244bead5f09a86e91bb2365cbcd1122ac68261e5647942485df++ stage_asset esptool-v4.9.dev3-linux-amd64.tar.gz espressif/arduino-esp32 212736075 4ecaf51836cbf4ea3c19840018bfef3b0b8cd8fc3c95f6e1e043ca5bbeab9bf0++ stage_asset mkspiffs-0.2.3-arduino-esp32-linux64.tar.gz igrr/mkspiffs 6442020 5e1a4ff41385e842f389f6b5254102a547e566a06b49babeffa93ef37115cb5d++ stage_asset x86_64-linux-gnu.mklittlefs-c41e51a.200706.tar.gz earlephilhower/esp-quick-toolchain 22621400 66e84dda0aad747517da3785125e05738a540948aab2b7eaa02855167a1eea53++ stage_asset esp32-3.1.1.zip espressif/arduino-esp32 218876848 e20982b2860eab4900ce16a0f2b7f9fc3ffb205e490dc933f625d53a5c9e8129arduino-cli core install esp32:[email protected]arduino-cli lib install "[email protected]"arduino-cli lib install "[email protected]"@@@ -34,6 +34,9 @@ class PreviewActivity : ComponentActivity() {) {when (screen) {"activity" -> ActivityScreen(preview = SAMPLE_ACTIVITY)++ "notify" -> ActivityScreen(preview = SAMPLE_NOTIFY)++ "camera" -> ApprovalsScreen(preview = SAMPLE_CAMERA)++ "location" -> ApprovalsScreen(preview = SAMPLE_LOCATION)"phone" -> PhoneScreen(previewInfo = SAMPLE_DEVICE, preview = true)"pair" -> PairScreen(initialCode = "ABCD-1234")else -> ApprovalsScreen(preview = SAMPLE_APPROVALS)@@@ -74,6 +77,15 @@ class PreviewActivity : ComponentActivity() {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_CAMERA = listOf(item("apr_camera", "Take one photo for your agent",++ "device.camera · Read the damaged label so I can order the right part.", agent = "Scout"))++++ val SAMPLE_LOCATION = listOf(item("apr_location", "Share your location once with your agent",++ "device.location.precise · Find a nearby open pharmacy.", agent = "Scout"))++++ val SAMPLE_NOTIFY = listOf(item("ntf_ready", "The report is ready",++ "notify · Your weekly report is ready to read.", bucket = "done", kind = "agent_task", agent = "Scout"))++val SAMPLE_DEVICE = JSONObject().put("device_id", "dev_01J9Z8K4Q7X2M5N8P3R6T0V").put("principal", "nod_01J9Z8K4Q7X2M5N8P3R6T0V")@@@ -1,8 +1,10 @@<?xml version="1.0" encoding="utf-8"?>--<manifest xmlns:android="http://schemas.android.com/apk/res/android">++<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"><uses-permission android:name="android.permission.INTERNET" />-- <uses-permission android:name="android.permission.CAMERA" />++ <uses-permission android:name="android.permission.CAMERA" tools:node="remove" />++ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />++ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><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" />@@@ -21,6 +23,14 @@android:supportsRtl="true"android:theme="@style/Theme.OpenVibe">++ <provider++ android:name="androidx.core.content.FileProvider"++ android:authorities="${applicationId}.devicephotos"++ android:exported="false"++ android:grantUriPermissions="true">++ <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/device_photo_paths" />++ </provider>++<activityandroid:name=".MainActivity"android:exported="true"@@@ -1,5 +1,8 @@package network.openvibe.link.data++import android.content.Context++import android.location.Location++import android.net.Uriimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport kotlinx.serialization.SerialName@@@ -9,6 +12,10 @@ import okhttp3.MediaType.Companion.toMediaTypeimport okhttp3.OkHttpClientimport okhttp3.Requestimport okhttp3.RequestBody.Companion.toRequestBody++import org.json.JSONObject++import java.io.ByteArrayOutputStream++import kotlin.math.max++import kotlin.math.roundimport java.util.concurrent.TimeUnit/** One item of the Action Center (`common.action-item@1`), which is what the Dock's activity tab draws. */@@@ -33,6 +40,14 @@ data class ActionItem() {val isApproval: Boolean get() = kind == "approval"val isPending: Boolean get() = bucket == "waiting" || state == "pending"++ val deviceKind: String? get() = when {++ summary?.startsWith("device.camera · ") == true -> "camera"++ summary?.startsWith("device.location") == true -> "location"++ else -> null++ }++ val deviceWhy: String? get() = if (deviceKind != null) summary?.substringAfter(" · ") else null++ val preciseRequested: Boolean get() = summary?.startsWith("device.location.precise · ") == true++ val isNotify: Boolean get() = service == "actor" && id.startsWith("ntf_") && summary?.startsWith("notify · ") == true}/** `network.me-actions-result@1`: the buckets, the counts, and whether a source did not answer. */@@@ -54,6 +69,7 @@ object Api {val NETWORK: String get() = Servers.networkconst val BOT = "https://openvibe.bot"const val DOWNLOAD = "https://openvibe.download"++ const val ACTOR = "https://openvibe.actor"const val APPROVE = "approve"const val DECLINE = "decline"@@@ -89,6 +105,17 @@ object Api {}}++ /** A short heartbeat only while Link's paired connection is live; Actor treats it as offline after 60 seconds. */++ suspend fun devicePresence(deviceId: String) = withContext(Dispatchers.IO) {++ if (!deviceId.matches(Regex("dev_[0-9A-HJKMNP-TV-Z]{26}"))) return@withContext++ val token = Session.token() ?: return@withContext++ val body = JSONObject().put("device_id", deviceId).toString()++ val request = Request.Builder().url("$ACTOR/api/v1/me/devices/presence")++ .header("Authorization", "Bearer $token")++ .post(body.toRequestBody(JSON_TYPE)).build()++ client.newCall(request).execute().use { /* a missed heartbeat makes the phone offline after a minute */ }++ }++/** 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.")@@@ -122,6 +149,58 @@ object Api {}}++ /** Device answers bypass Network's approve/decline shortcut: Actor checks the one photo or location itself. */++ suspend fun answerDevice(item: ActionItem, answer: JSONObject) = withContext(Dispatchers.IO) {++ if (item.service != "actor" || item.deviceKind == null) throw ApiException("This is not a device request.")++ val token = Session.token() ?: throw ApiException("Sign in to answer your agent.")++ val request = Request.Builder().url("$ACTOR/api/v1/me/approvals/${item.id}/answer")++ .header("Authorization", "Bearer $token").header("Accept", "application/json")++ .post(answer.toString().toRequestBody(JSON_TYPE)).build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (!response.isSuccessful) throw ApiException(problem(text) ?: "Actor answered ${response.code}.")++ }++ }++++ /** The system camera's one output; Actor validates, strips metadata and binds it to this approval. */++ suspend fun uploadPhoto(item: ActionItem, context: Context, uri: Uri): String = withContext(Dispatchers.IO) {++ if (item.deviceKind != "camera") throw ApiException("This is not a photo request.")++ val token = Session.token() ?: throw ApiException("Sign in to send your photo.")++ val bytes = context.contentResolver.openInputStream(uri)?.use { stream ->++ val output = ByteArrayOutputStream()++ val chunk = ByteArray(64 * 1024)++ while (true) {++ val count = stream.read(chunk)++ if (count < 0) break++ if (output.size() + count > 10 * 1024 * 1024) throw ApiException("The photo is over 10 MB.")++ output.write(chunk, 0, count)++ }++ output.toByteArray()++ } ?: throw ApiException("The camera did not return a photo.")++ if (bytes.isEmpty()) throw ApiException("The camera did not return a photo.")++ val request = Request.Builder().url("$ACTOR/api/v1/me/approvals/${item.id}/photo")++ .header("Authorization", "Bearer $token").header("Accept", "application/json")++ .post(bytes.toRequestBody("image/jpeg".toMediaType())).build()++ client.newCall(request).execute().use { response ->++ val text = response.body?.string().orEmpty()++ if (!response.isSuccessful) throw ApiException(problem(text) ?: "Actor answered ${response.code}.")++ JSONObject(text).optString("media_id").takeIf { it.startsWith("med_") }++ ?: throw ApiException("Actor did not return a photo id.")++ }++ }++++ fun photoAnswer(mediaId: String): JSONObject = JSONObject().put("decision", "answer")++ .put("photo", JSONObject().put("media_id", mediaId))++++ fun locationAnswer(location: Location, precise: Boolean): JSONObject {++ val latitude = if (precise) location.latitude else round(location.latitude * 100.0) / 100.0++ val longitude = if (precise) location.longitude else round(location.longitude * 100.0) / 100.0++ return JSONObject().put("decision", "answer").put("location", JSONObject()++ .put("lat", latitude).put("lng", longitude)++ .put("accuracy_m", if (precise) location.accuracy.toDouble() else max(1000.0, location.accuracy.toDouble()))++ .put("precise", precise))++ }++private fun problem(text: String): String? = try {(json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject)?.get("detail")?.toString()?.trim('"')?.takeIf { it.isNotEmpty() }nnew file mode 100644@@@ -0,0 +1,35 @@++package network.openvibe.link.data++++import android.content.Context++import android.location.Location++import android.location.LocationListener++import android.location.LocationManager++import android.os.Looper++import kotlinx.coroutines.suspendCancellableCoroutine++import kotlinx.coroutines.withTimeout++import kotlin.coroutines.resume++++/** One location reading after the person's tap and Android's permission prompt. No listener stays installed. */++object DeviceLocation {++ suspend fun once(context: Context, precise: Boolean): Location = withTimeout(15_000) {++ val manager = context.getSystemService(LocationManager::class.java)++ ?: throw ApiException("Location is unavailable on this phone.")++ val provider = if (precise) LocationManager.GPS_PROVIDER else LocationManager.NETWORK_PROVIDER++ if (!manager.isProviderEnabled(provider)) throw ApiException("${if (precise) "Precise" else "Approximate"} location is off on this phone.")++ suspendCancellableCoroutine { continuation ->++ val listener = object : LocationListener {++ override fun onLocationChanged(location: Location) {++ manager.removeUpdates(this)++ if (continuation.isActive) continuation.resume(location)++ }++ }++ continuation.invokeOnCancellation { manager.removeUpdates(listener) }++ try {++ manager.requestSingleUpdate(provider, listener, Looper.getMainLooper())++ } catch (e: SecurityException) {++ manager.removeUpdates(listener)++ continuation.cancel(ApiException("Location permission was not granted."))++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,27 @@++package network.openvibe.link.data++++import android.graphics.BitmapFactory++import com.google.zxing.BinaryBitmap++import com.google.zxing.MultiFormatReader++import com.google.zxing.RGBLuminanceSource++import com.google.zxing.common.HybridBinarizer++import java.io.File++++/** Read a pairing QR from one system-camera photo; no camera permission or live camera stays in this app. */++object PairPhoto {++ fun decode(file: File): String? {++ return try {++ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }++ BitmapFactory.decodeFile(file.path, bounds)++ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null++ var sample = 1++ while (bounds.outWidth / sample > 1600 || bounds.outHeight / sample > 1600) sample *= 2++ val bitmap = BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null++ try {++ val pixels = IntArray(bitmap.width * bitmap.height)++ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)++ MultiFormatReader().decode(BinaryBitmap(HybridBinarizer(RGBLuminanceSource(bitmap.width, bitmap.height, pixels)))).text++ } finally { bitmap.recycle() }++ } catch (_: Exception) { null }++ }++}@@@ -91,6 +91,14 @@ object Store {prefs.edit().putBoolean("stay_connected", on).apply()}++ /** Non-secret Action Center cursor: a local notification is raised at most once per item. */++ fun noticeCursor(): String = prefs.getString("notice_cursor", null)++ ?: java.time.Instant.now().minusSeconds(60).toString()++++ fun advanceNoticeCursor(at: String) {++ if (at > (prefs.getString("notice_cursor", null) ?: "")) prefs.edit().putString("notice_cursor", at).apply()++ }++private fun encryptedKey(name: String) = "enc_$name"private fun write(name: String, value: String) {@@@ -14,10 +14,18 @@ import android.os.IBinderimport android.os.Looperimport androidx.core.app.NotificationCompatimport androidx.core.content.ContextCompat++import kotlinx.coroutines.CoroutineScope++import kotlinx.coroutines.Dispatchers++import kotlinx.coroutines.Job++import kotlinx.coroutines.SupervisorJob++import kotlinx.coroutines.cancel++import kotlinx.coroutines.launchimport network.openvibe.link.MainActivityimport network.openvibe.link.Rimport network.openvibe.link.data.LinkController++import network.openvibe.link.data.Apiimport network.openvibe.link.data.Store++import org.json.JSONObject/*** "Stay connected": the phone's one outbound connection to OpenVibe.Bot, held by an opt-in foreground service with a@@@ -27,6 +35,9 @@ import network.openvibe.link.data.Storeclass StayConnectedService : Service() {private val main = Handler(Looper.getMainLooper())private var tick: Runnable? = null++ private val pollScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)++ private var polling: Job? = null++ private var lastPoll = 0Loverride fun onBind(intent: Intent?): IBinder? = null@@@ -52,6 +63,7 @@ class StayConnectedService : Service() {override fun onDestroy() {tick?.let { main.removeCallbacks(it) }tick = null++ pollScope.cancel()if (!Store.stayConnected) LinkController.stop()super.onDestroy()}@@@ -67,6 +79,10 @@ class StayConnectedService : Service() {}val manager = getSystemService(NotificationManager::class.java)manager?.notify(NOTIFICATION_ID, notification(text))++ if (Store.stayConnected && Store.signedIn && System.currentTimeMillis() - lastPoll >= 20_000 && polling?.isActive != true) {++ lastPoll = System.currentTimeMillis()++ polling = pollScope.launch { pollNotices() }++ }if (LinkController.state != "revoked") main.postDelayed(this, 5_000)}}@@@ -74,6 +90,35 @@ class StayConnectedService : Service() {main.post(runnable)}++ private suspend fun pollNotices() {++ try {++ if (LinkController.connected) {++ val id = LinkController.info()?.let { JSONObject(it).optString("device_id") }++ if (!id.isNullOrBlank()) runCatching { Api.devicePresence(id) }++ }++ val actions = Api.actions()++ val newest = actions.buckets.values.flatten().filter { it.isNotify && it.createdAt != null }++ .sortedBy { it.createdAt }++ val cursor = Store.noticeCursor()++ val manager = getSystemService(NotificationManager::class.java) ?: return++ for (item in newest.filter { it.createdAt!! > cursor }.takeLast(5)) {++ val open = PendingIntent.getActivity(this, item.id.hashCode(), Intent(this, MainActivity::class.java),++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)++ val notice = NotificationCompat.Builder(this, AGENTS_CHANNEL)++ .setSmallIcon(R.drawable.ic_notify)++ .setContentTitle(listOfNotNull(item.agent, item.title).joinToString(" · "))++ .setContentText(item.summary?.removePrefix("notify · ").orEmpty())++ .setAutoCancel(true).setContentIntent(open)++ .setCategory(NotificationCompat.CATEGORY_MESSAGE)++ .setPriority(NotificationCompat.PRIORITY_DEFAULT).build()++ try { manager.notify(item.id.hashCode(), notice) } catch (_: SecurityException) { /* notifications are off */ }++ }++ newest.lastOrNull()?.createdAt?.let(Store::advanceNoticeCursor)++ } catch (_: Exception) {++ // A transient Action Center or session failure is retried on the next poll.++ }++ }++private fun notification(text: String): Notification {val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java),@@@ -100,6 +145,7 @@ class StayConnectedService : Service() {const val ACTION_START = "network.openvibe.link.STAY_START"const val ACTION_STOP = "network.openvibe.link.STAY_STOP"const val CHANNEL = "stay_connected"++ const val AGENTS_CHANNEL = "your_agents"private const val NOTIFICATION_ID = 1001fun ensureChannel(context: Context) {@@@ -112,6 +158,11 @@ class StayConnectedService : Service() {},)}++ if (manager.getNotificationChannel(AGENTS_CHANNEL) == null) {++ manager.createNotificationChannel(NotificationChannel(AGENTS_CHANNEL, "Your agents", NotificationManager.IMPORTANCE_DEFAULT).apply {++ description = "Notifications your agents asked to show on this phone"++ })++ }}fun start(context: Context) {@@@ -1,5 +1,9 @@package network.openvibe.link.ui++import android.Manifest++import android.content.pm.PackageManager++import androidx.activity.compose.rememberLauncherForActivityResult++import androidx.activity.result.contract.ActivityResultContractsimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.fillMaxWidth@@@ -34,11 +38,19 @@ import androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.text.font.FontWeightimport androidx.compose.ui.unit.dpimport androidx.fragment.app.FragmentActivity++import androidx.core.content.ContextCompat++import androidx.core.content.FileProviderimport kotlinx.coroutines.launch++import org.json.JSONObject++import java.io.File++import java.util.UUIDimport network.openvibe.link.approval.ApprovalCategoryimport network.openvibe.link.approval.ApprovalRulesimport network.openvibe.link.approval.Biometricsimport network.openvibe.link.data.ActionItem++import network.openvibe.link.data.Api++import network.openvibe.link.data.DeviceLocation++import network.openvibe.link.data.LinkControllerimport network.openvibe.link.data.Store/** Each category's tone and icon: money amber, deleting red, passwords violet, a new recipient blue. */@@@ -69,6 +81,59 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {val activity = context as? FragmentActivityval scope = rememberCoroutineScope()var working by remember { mutableStateOf<String?>(null) }++ var capture by remember { mutableStateOf<Pair<ActionItem, File>?>(null) }++ var locationRequest by remember { mutableStateOf<Pair<ActionItem, Boolean>?>(null) }++++ val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { saved ->++ val pending = capture++ capture = null++ if (pending != null) {++ val (item, file) = pending++ if (saved) {++ working = item.id++ scope.launch {++ try {++ val uri = FileProvider.getUriForFile(context, "${context.packageName}.devicephotos", file)++ val mediaId = Api.uploadPhoto(item, context, uri)++ Api.answerDevice(item, Api.photoAnswer(mediaId))++ ActionsRepo.refresh()++ Messages.show("Your photo was shared once.")++ } catch (e: Exception) {++ Messages.show(e.message ?: "The photo was not shared.")++ } finally {++ file.delete()++ working = null++ }++ }++ } else file.delete()++ }++ }++ val locationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->++ val pending = locationRequest++ locationRequest = null++ if (pending != null) {++ val (item, precise) = pending++ val coarse = grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true ||++ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED++ val fine = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||++ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED++ if (!coarse || (precise && !fine)) {++ Messages.show(if (precise) "Precise location was not allowed. You can choose approximate sharing." else "Location was not allowed.")++ } else {++ working = item.id++ scope.launch {++ try {++ val point = DeviceLocation.once(context, precise)++ Api.answerDevice(item, Api.locationAnswer(point, precise))++ ActionsRepo.refresh()++ Messages.show("Your location was shared once.")++ } catch (e: Exception) {++ Messages.show(e.message ?: "The location was not shared.")++ } finally { working = null }++ }++ }++ }++ }LaunchedEffect(Store.signedIn) {if (preview == null && Store.signedIn) ActionsRepo.refresh()@@@ -111,6 +176,7 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {items(waiting, key = { it.service + it.id }) { item ->val category = ApprovalRules.classify(item.category, item.kind, item.title, item.summary)++ val deviceKind = item.deviceKindval decide: (Boolean) -> Unit = { approve ->val send = {working = item.id@@@ -138,20 +204,59 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {}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())++ ToneChip(if (deviceKind == "camera") "Photo request" else if (deviceKind == "location") "Location request" else category.label,++ if (deviceKind != null) Tone.Info else 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 {++ (if (deviceKind != null) item.deviceWhy else 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)) {++ if (deviceKind != null && preview == null && !LinkController.connected) {++ Text("This phone is offline. Connect it before answering; you can use the approval page in a browser.",++ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)++ }++ if (deviceKind == "camera") {++ Button(enabled = working == null && (preview != null || LinkController.connected),++ onClick = {++ val folder = File(context.cacheDir, "device-photos").apply { mkdirs() }++ val file = File(folder, "${UUID.randomUUID()}.jpg")++ val uri = FileProvider.getUriForFile(context, "${context.packageName}.devicephotos", file)++ capture = item to file++ camera.launch(uri)++ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Take a photo") }++ } else if (deviceKind == "location") {++ Button(enabled = working == null && (preview != null || LinkController.connected),++ onClick = {++ locationRequest = item to false++ locationPermission.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION))++ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Share where I am once") }++ if (item.preciseRequested) {++ OutlinedButton(enabled = working == null && (preview != null || LinkController.connected),++ onClick = {++ locationRequest = item to true++ locationPermission.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION))++ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Share precise location once") }++ }++ }++ if (deviceKind != null) {++ OutlinedButton(enabled = working == null, onClick = {++ working = item.id++ scope.launch {++ try {++ Api.answerDevice(item, JSONObject().put("decision", "decline"))++ ActionsRepo.refresh()++ } catch (e: Exception) { Messages.show(e.message ?: "The answer was not sent.") }++ finally { working = null }++ }++ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Decline") }++ } else Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {Button(enabled = working == null,onClick = { decide(true) },@@@ -1,5 +1,6 @@package network.openvibe.link.ui++import android.os.Buildimport androidx.compose.material3.FilledTonalButtonimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Arrangement@@@ -13,9 +14,6 @@ import androidx.compose.material.icons.outlined.QrCodeScannerimport androidx.compose.material3.Iconimport androidx.compose.ui.Alignmentimport androidx.compose.ui.text.font.FontWeight--import android.Manifest--import android.content.pm.PackageManager--import android.os.Buildimport androidx.activity.compose.rememberLauncherForActivityResultimport androidx.activity.result.contract.ActivityResultContractsimport androidx.compose.foundation.layout.Column@@@ -40,12 +38,13 @@ import androidx.compose.runtime.setValueimport androidx.compose.ui.Modifierimport androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.unit.dp--import androidx.core.content.ContextCompat--import com.journeyapps.barcodescanner.ScanContract--import com.journeyapps.barcodescanner.ScanOptions++import androidx.core.content.FileProvider++import java.io.File++import java.util.UUIDimport kotlinx.coroutines.launchimport network.openvibe.link.data.Apiimport network.openvibe.link.data.PairLinks++import network.openvibe.link.data.PairPhotoimport network.openvibe.link.data.Store/**@@@ -60,6 +59,7 @@ fun PairScreen(initialCode: String?) {var pairing by rememberSaveable { mutableStateOf<String?>(null) }var busy by remember { mutableStateOf(false) }var error by remember { mutableStateOf<String?>(null) }++ var scanFile by remember { mutableStateOf<File?>(null) }LaunchedEffect(initialCode) {if (!initialCode.isNullOrBlank()) code = initialCode@@@ -71,22 +71,24 @@ fun PairScreen(initialCode: String?) {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++ val scanner = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { saved ->++ val file = scanFile++ scanFile = null++ if (saved && file != null) {++ val link = PairLinks.codeFromScan(PairPhoto.decode(file))++ when {++ link?.refusedServer != null -> {++ error = "That QR points at ${link.refusedServer}, not OpenVibe Network."++ Messages.show(error)++ }++ link?.code != null -> {++ code = link.code++ pairing = link.pairing++ }++ else -> Messages.show("The photo has no OpenVibe pairing QR. Try again or type the code.")}-- 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.")++ file?.delete()}Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp)) {@@@ -103,16 +105,15 @@ fun PairScreen(initialCode: String?) {}Button(onClick = {-- if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {-- launchScanner(scanner)-- } else {-- askCamera.launch(Manifest.permission.CAMERA)-- }++ val folder = File(context.cacheDir, "pair-photos").apply { mkdirs() }++ val file = File(folder, "${UUID.randomUUID()}.jpg")++ scanFile = file++ scanner.launch(FileProvider.getUriForFile(context, "${context.packageName}.devicephotos", file))},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("Photograph the QR", fontWeight = FontWeight.Bold)}Text("Or type the code",@@@ -169,16 +170,6 @@ fun PairScreen(initialCode: String?) {}}--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. */@Composableprivate fun PairStep(n: Int, text: String) {@@@ -8,6 +8,8 @@ import network.openvibe.link.data.ActionsDocimport network.openvibe.link.data.Apiimport network.openvibe.link.data.Sessionimport network.openvibe.link.data.Store++import network.openvibe.link.data.LinkController++import org.json.JSONObject/** Intents the activity received, for the Compose tree to read (a deep link can arrive while it is already drawn). */object Incoming {@@@ -50,6 +52,10 @@ object ActionsRepo {loading = trueerror = nulltry {++ if (LinkController.connected) {++ val id = LinkController.info()?.let { JSONObject(it).optString("device_id") }++ if (!id.isNullOrBlank()) runCatching { Api.devicePresence(id) }++ }doc = Api.actions()} catch (e: Exception) {error = e.message ?: "Could not read your activity."nnew file mode 100644@@@ -0,0 +1,5 @@++<?xml version="1.0" encoding="utf-8"?>++<paths xmlns:android="http://schemas.android.com/apk/res/android">++ <cache-path name="device-photos" path="device-photos/" />++ <cache-path name="pair-photos" path="pair-photos/" />++</paths>nnew file mode 100644@@@ -0,0 +1,29 @@++package network.openvibe.link++++import network.openvibe.link.data.ActionItem++import org.junit.Assert.assertEquals++import org.junit.Assert.assertFalse++import org.junit.Assert.assertTrue++import org.junit.Test++++class DeviceActionsTest {++ @Test fun cameraAndLocationAreNotOrdinaryApprovals() {++ val photo = ActionItem(service = "actor", id = "apr_camera", kind = "approval",++ summary = "device.camera · Read the sign before suggesting a route")++ val where = ActionItem(service = "actor", id = "apr_location", kind = "approval",++ summary = "device.location.precise · Find a nearby open pharmacy")++ assertEquals("camera", photo.deviceKind)++ assertEquals("Read the sign before suggesting a route", photo.deviceWhy)++ assertEquals("location", where.deviceKind)++ assertTrue(where.preciseRequested)++ assertFalse(photo.preciseRequested)++ }++++ @Test fun onlyFlaggedActorItemsArePhoneNotifications() {++ val notice = ActionItem(service = "actor", id = "ntf_1", kind = "agent_task",++ summary = "notify · The report is ready")++ assertTrue(notice.isNotify)++ assertFalse(notice.copy(id = "tsk_1").isNotify)++ assertFalse(notice.copy(service = "chat").isNotify)++ }++}@@@ -18,6 +18,9 @@ import network.openvibe.link.ui.ApprovalsScreenimport network.openvibe.link.ui.LinkThemeimport network.openvibe.link.ui.PairScreenimport network.openvibe.link.ui.PhoneScreen++import java.util.TimeZone++import org.junit.After++import org.junit.Beforeimport org.junit.Ruleimport org.junit.Test@@@ -29,6 +32,16 @@ import org.junit.Testclass ScreensSnapshotTest {@get:Ruleval paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5, theme = "android:Theme.Material.NoActionBar")++ private lateinit var previousTimeZone: TimeZone++++ @Before fun useSnapshotTimeZone() {++ previousTimeZone = TimeZone.getDefault()++ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))++ }++++ @After fun restoreTimeZone() {++ TimeZone.setDefault(previousTimeZone)++ }/** 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 {@@@ -48,6 +61,12 @@ class ScreensSnapshotTest {@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 cameraDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_CAMERA) }++ @Test fun cameraLight() = shot(false) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_CAMERA) }++ @Test fun locationDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_LOCATION) }++ @Test fun locationLight() = shot(false) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_LOCATION) }++ @Test fun notifyDark() = shot(true) { ActivityScreen(preview = PreviewActivity.SAMPLE_NOTIFY) }++ @Test fun notifyLight() = shot(false) { ActivityScreen(preview = PreviewActivity.SAMPLE_NOTIFY) }@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) }BBinary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png differBBinary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png differBBinary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png differBBinary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png 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_cameraDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraLight.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationLight.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyDark.png differnnew file mode 100644BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyLight.png differBBinary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png differBBinary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png differ@@@ -17,7 +17,7 @@ adb shell settings put global animator_duration_scale 0 || trueapk=$(find android/app/build/outputs/apk/debug -name "*.apk" | head -n 1)adb install -r "$apk"--for screen in pair approvals activity phone; do++for screen in pair approvals camera location notify activity phone; doadb shell am force-stop network.openvibe.link || trueadb shell am start -W -n network.openvibe.link/.PreviewActivity --es screen "$screen" >/dev/nullsleep 2@@@ -1,5 +1,6 @@pluginManagement {repositories {++ mavenLocal()google()mavenCentral()gradlePluginPortal()@@@ -9,6 +10,7 @@ pluginManagement {dependencyResolutionManagement {repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)repositories {++ mavenLocal()google()mavenCentral()}
@@ -12,7 +12,7 @@ permissions:
@@ -12,7 +12,7 @@ permissions:
jobs:
jobs:
apk:
apk:
name: Go core, unit tests and the APK
name: Go core, unit tests and the APK
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
permissions:
contents: write # the APK is attached to the release on a tag
contents: write # the APK is attached to the release on a tag
steps:
steps:
@@ -21,15 +21,37 @@ jobs:
@@ -21,15 +21,37 @@ jobs:
with:
with:
distribution: temurin
distribution: temurin
java-version: "17"
java-version: "17"
- - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
- with:
- go-version-file: go.mod
+ - name: Install pinned Go
+ run: |
+ curl -fL --retry 3 https://go.dev/dl/go1.24.7.linux-amd64.tar.gz -o "$RUNNER_TEMP/go1.24.7.linux-amd64.tar.gz"
+ echo 'da18191ddb7db8a9339816f3e2b54bdded8047cdc2a5d67059478f8d1595c43f '"$RUNNER_TEMP/go1.24.7.linux-amd64.tar.gz" | sha256sum -c -
+ tar -xzf "$RUNNER_TEMP/go1.24.7.linux-amd64.tar.gz" -C "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP/go/bin" >> "$GITHUB_PATH"
+ echo "$("$RUNNER_TEMP/go/bin/go" env GOPATH)/bin" >> "$GITHUB_PATH"
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
with:
node-version: "20"
node-version: "20"
- - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- with:
- gradle-version: "8.11.1"
+ - name: Install pinned Gradle
+ run: |
+ curl -fL --retry 3 -H 'Accept: application/octet-stream' \
+ https://api.github.com/repos/gradle/gradle-distributions/releases/assets/207876967 \
+ -o "$RUNNER_TEMP/gradle-8.11.1-bin.zip"
+ echo 'f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 '"$RUNNER_TEMP/gradle-8.11.1-bin.zip" | sha256sum -c -
+ unzip -q "$RUNNER_TEMP/gradle-8.11.1-bin.zip" -d "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP/gradle-8.11.1/bin" >> "$GITHUB_PATH"
+
+ - name: Cache pinned Kotlin compiler
+ run: |
+ artifact="$HOME/.m2/repository/org/jetbrains/kotlin/kotlin-compiler-embeddable/2.0.21"
+ mkdir -p "$artifact"
+ curl -fL --retry 3 -H 'Accept: application/octet-stream' \
+ https://api.github.com/repos/JetBrains/kotlin/releases/assets/587823233 \
+ -o "$artifact/kotlin-compiler-embeddable-2.0.21.jar"
+ echo '9fa8cdd1de0dccffe154c997d423ec6b5f53cd6d9177e3a77a9b0de03fb1bc81 '"$artifact/kotlin-compiler-embeddable-2.0.21.jar" | sha256sum -c -
+ curl -fL --retry 3 \
+ https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-compiler-embeddable/2.0.21/kotlin-compiler-embeddable-2.0.21.pom \
+ -o "$artifact/kotlin-compiler-embeddable-2.0.21.pom"
+ echo 'bd4656a46ec418252e5bc5e1c20eb2029fb2a8e0e3cc94eeddfac7e87c8cd5b6 '"$artifact/kotlin-compiler-embeddable-2.0.21.pom" | sha256sum -c -
- name: Android NDK (gomobile needs one)
- name: Android NDK (gomobile needs one)
run: |
run: |
@@ -76,7 +76,7 @@ jobs:
@@ -76,7 +76,7 @@ jobs:
build:
build:
name: cross-compile
name: cross-compile
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/setup-go@v5
@@ -203,7 +203,7 @@ jobs:
@@ -203,7 +203,7 @@ jobs:
shell:
shell:
name: shellcheck
name: shellcheck
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-plan-logins.sh scripts/test-install-claim.sh
- run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-plan-logins.sh scripts/test-install-claim.sh
@@ -216,14 +216,21 @@ jobs:
@@ -216,14 +216,21 @@ jobs:
esp32-conformance:
esp32-conformance:
name: esp32 protocol conformance (desktop)
name: esp32 protocol conformance (desktop)
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
+ - name: Cache pinned ArduinoJson header
+ run: |
+ mkdir -p esp32/test/.deps
+ curl -fL --retry 3 -H 'Accept: application/octet-stream' \
+ https://api.github.com/repos/bblanchon/ArduinoJson/releases/assets/265662161 \
+ -o esp32/test/.deps/ArduinoJson.h
+ echo 'a05ac98f4481d2398c103ca5ffcce8e2fcd7fa2fa1d8d9f38d5757454f448d97 esp32/test/.deps/ArduinoJson.h' | sha256sum -c -
- run: sh esp32/test/run.sh
- run: sh esp32/test/run.sh
esp32-compile:
esp32-compile:
name: esp32 compile (arduino-cli)
name: esp32 compile (arduino-cli)
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- uses: arduino/setup-arduino-cli@v2
- uses: arduino/setup-arduino-cli@v2
@@ -232,6 +239,24 @@ jobs:
@@ -232,6 +239,24 @@ jobs:
arduino-cli config init
arduino-cli config init
arduino-cli config add board_manager.additional_urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli config add board_manager.additional_urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli core update-index
arduino-cli core update-index
+ cache="$HOME/.arduino15/staging/packages"
+ mkdir -p "$cache"
+ stage_asset() {
+ file=$1 repo=$2 asset=$3 checksum=$4
+ curl -fLsS --retry 3 -H 'Accept: application/octet-stream' \
+ "https://api.github.com/repos/$repo/releases/assets/$asset" -o "$cache/$file"
+ printf '%s %s\n' "$checksum" "$cache/$file" | sha256sum -c -
+ }
+ stage_asset esp32-arduino-libs-idf-release_v5.3-cfea4f7c-v1.zip espressif/esp32-arduino-lib-builder 218373742 1099291229a9be453c771c5867b8487a0266db9246b672417337b8f511d7f820
+ stage_asset xtensa-esp-elf-13.2.0_20240530-x86_64-linux-gnu.tar.gz espressif/crosstool-NG 171009396 bce77e8480701d5a90545369d1b5848f6048eb39c0022d2446d1e33a8e127490
+ stage_asset xtensa-esp-elf-gdb-14.2_20240403-x86_64-linux-gnu.tar.gz espressif/binutils-gdb 160240516 9d68472d4cba5cf8c2b79d94f86f92c828e76a632bd1e6be5e7706e5b304d36e
+ stage_asset riscv32-esp-elf-13.2.0_20240530-x86_64-linux-gnu.tar.gz espressif/crosstool-NG 171009184 e7fbfffbb19dcd3764a9848a141bf44e19ad0b48e0bd1515912345c26fe52fba
+ stage_asset riscv32-esp-elf-gdb-14.2_20240403-x86_64-linux-gnu.tar.gz espressif/binutils-gdb 160240334 ce004bc0bbd71b246800d2d13b239218b272a38bd528e316f21f1af2db8a4b13
+ stage_asset openocd-esp32-linux-amd64-0.12.0-esp32-20241016.tar.gz espressif/openocd-esp32 200012418 e82b0f036dc99244bead5f09a86e91bb2365cbcd1122ac68261e5647942485df
+ stage_asset esptool-v4.9.dev3-linux-amd64.tar.gz espressif/arduino-esp32 212736075 4ecaf51836cbf4ea3c19840018bfef3b0b8cd8fc3c95f6e1e043ca5bbeab9bf0
+ stage_asset mkspiffs-0.2.3-arduino-esp32-linux64.tar.gz igrr/mkspiffs 6442020 5e1a4ff41385e842f389f6b5254102a547e566a06b49babeffa93ef37115cb5d
+ stage_asset x86_64-linux-gnu.mklittlefs-c41e51a.200706.tar.gz earlephilhower/esp-quick-toolchain 22621400 66e84dda0aad747517da3785125e05738a540948aab2b7eaa02855167a1eea53
+ stage_asset esp32-3.1.1.zip espressif/arduino-esp32 218876848 e20982b2860eab4900ce16a0f2b7f9fc3ffb205e490dc933f625d53a5c9e8129
arduino-cli core install esp32:[email protected]
arduino-cli core install esp32:[email protected]
arduino-cli lib install "[email protected]"
arduino-cli lib install "[email protected]"
arduino-cli lib install "[email protected]"
arduino-cli lib install "[email protected]"
@@ -34,6 +34,9 @@ class PreviewActivity : ComponentActivity() {
@@ -34,6 +34,9 @@ class PreviewActivity : ComponentActivity() {
) {
) {
when (screen) {
when (screen) {
"activity" -> ActivityScreen(preview = SAMPLE_ACTIVITY)
"activity" -> ActivityScreen(preview = SAMPLE_ACTIVITY)
+ "notify" -> ActivityScreen(preview = SAMPLE_NOTIFY)
+ "camera" -> ApprovalsScreen(preview = SAMPLE_CAMERA)
+ "location" -> ApprovalsScreen(preview = SAMPLE_LOCATION)
"phone" -> PhoneScreen(previewInfo = SAMPLE_DEVICE, preview = true)
"phone" -> PhoneScreen(previewInfo = SAMPLE_DEVICE, preview = true)
"pair" -> PairScreen(initialCode = "ABCD-1234")
"pair" -> PairScreen(initialCode = "ABCD-1234")
else -> ApprovalsScreen(preview = SAMPLE_APPROVALS)
else -> ApprovalsScreen(preview = SAMPLE_APPROVALS)
@@ -74,6 +77,15 @@ class PreviewActivity : ComponentActivity() {
@@ -74,6 +77,15 @@ class PreviewActivity : ComponentActivity() {
item("tsk_5", "Index the photo library", "Stopped: the drive was full", bucket = "failed", kind = "agent_task", at = "2026-11-06T12:10: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_CAMERA = listOf(item("apr_camera", "Take one photo for your agent",
+ "device.camera · Read the damaged label so I can order the right part.", agent = "Scout"))
+
+ val SAMPLE_LOCATION = listOf(item("apr_location", "Share your location once with your agent",
+ "device.location.precise · Find a nearby open pharmacy.", agent = "Scout"))
+
+ val SAMPLE_NOTIFY = listOf(item("ntf_ready", "The report is ready",
+ "notify · Your weekly report is ready to read.", bucket = "done", kind = "agent_task", agent = "Scout"))
+
val SAMPLE_DEVICE = JSONObject()
val SAMPLE_DEVICE = JSONObject()
.put("device_id", "dev_01J9Z8K4Q7X2M5N8P3R6T0V")
.put("device_id", "dev_01J9Z8K4Q7X2M5N8P3R6T0V")
.put("principal", "nod_01J9Z8K4Q7X2M5N8P3R6T0V")
.put("principal", "nod_01J9Z8K4Q7X2M5N8P3R6T0V")
@@ -1,8 +1,10 @@
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET" />
- <uses-permission android:name="android.permission.CAMERA" />
+ <uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -21,6 +23,14 @@
@@ -21,6 +23,14 @@
android:supportsRtl="true"
android:supportsRtl="true"
android:theme="@style/Theme.OpenVibe">
android:theme="@style/Theme.OpenVibe">
+ <provider
+ android:name="androidx.core.content.FileProvider"
+ android:authorities="${applicationId}.devicephotos"
+ android:exported="false"
+ android:grantUriPermissions="true">
+ <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/device_photo_paths" />
+ </provider>
+
<activity
<activity
android:name=".MainActivity"
android:name=".MainActivity"
android:exported="true"
android:exported="true"
@@ -1,5 +1,8 @@
@@ -1,5 +1,8 @@
package network.openvibe.link.data
package network.openvibe.link.data
+import android.content.Context
+import android.location.Location
+import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.SerialName
@@ -9,6 +12,10 @@ import okhttp3.MediaType.Companion.toMediaType
@@ -9,6 +12,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import kotlin.math.max
+import kotlin.math.round
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeUnit
/** One item of the Action Center (`common.action-item@1`), which is what the Dock's activity tab draws. */
/** One item of the Action Center (`common.action-item@1`), which is what the Dock's activity tab draws. */
@@ -33,6 +40,14 @@ data class ActionItem(
@@ -33,6 +40,14 @@ data class ActionItem(
) {
) {
val isApproval: Boolean get() = kind == "approval"
val isApproval: Boolean get() = kind == "approval"
val isPending: Boolean get() = bucket == "waiting" || state == "pending"
val isPending: Boolean get() = bucket == "waiting" || state == "pending"
+ val deviceKind: String? get() = when {
+ summary?.startsWith("device.camera · ") == true -> "camera"
+ summary?.startsWith("device.location") == true -> "location"
+ else -> null
+ }
+ val deviceWhy: String? get() = if (deviceKind != null) summary?.substringAfter(" · ") else null
+ val preciseRequested: Boolean get() = summary?.startsWith("device.location.precise · ") == true
+ val isNotify: Boolean get() = service == "actor" && id.startsWith("ntf_") && summary?.startsWith("notify · ") == true
}
}
/** `network.me-actions-result@1`: the buckets, the counts, and whether a source did not answer. */
/** `network.me-actions-result@1`: the buckets, the counts, and whether a source did not answer. */
@@ -54,6 +69,7 @@ object Api {
@@ -54,6 +69,7 @@ object Api {
val NETWORK: String get() = Servers.network
val NETWORK: String get() = Servers.network
const val BOT = "https://openvibe.bot"
const val BOT = "https://openvibe.bot"
const val DOWNLOAD = "https://openvibe.download"
const val DOWNLOAD = "https://openvibe.download"
+ const val ACTOR = "https://openvibe.actor"
const val APPROVE = "approve"
const val APPROVE = "approve"
const val DECLINE = "decline"
const val DECLINE = "decline"
@@ -89,6 +105,17 @@ object Api {
@@ -89,6 +105,17 @@ object Api {
}
}
}
}
+ /** A short heartbeat only while Link's paired connection is live; Actor treats it as offline after 60 seconds. */
+ suspend fun devicePresence(deviceId: String) = withContext(Dispatchers.IO) {
+ if (!deviceId.matches(Regex("dev_[0-9A-HJKMNP-TV-Z]{26}"))) return@withContext
+ val token = Session.token() ?: return@withContext
+ val body = JSONObject().put("device_id", deviceId).toString()
+ val request = Request.Builder().url("$ACTOR/api/v1/me/devices/presence")
+ .header("Authorization", "Bearer $token")
+ .post(body.toRequestBody(JSON_TYPE)).build()
+ client.newCall(request).execute().use { /* a missed heartbeat makes the phone offline after a minute */ }
+ }
+
/** Answer one Actor approval as the person themself (the same decision /me/approvals makes). */
/** 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) {
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.")
if (item.service != "actor" || !item.isApproval) throw ApiException("This item is not decided here.")
@@ -122,6 +149,58 @@ object Api {
@@ -122,6 +149,58 @@ object Api {
}
}
}
}
+ /** Device answers bypass Network's approve/decline shortcut: Actor checks the one photo or location itself. */
+ suspend fun answerDevice(item: ActionItem, answer: JSONObject) = withContext(Dispatchers.IO) {
+ if (item.service != "actor" || item.deviceKind == null) throw ApiException("This is not a device request.")
+ val token = Session.token() ?: throw ApiException("Sign in to answer your agent.")
+ val request = Request.Builder().url("$ACTOR/api/v1/me/approvals/${item.id}/answer")
+ .header("Authorization", "Bearer $token").header("Accept", "application/json")
+ .post(answer.toString().toRequestBody(JSON_TYPE)).build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (!response.isSuccessful) throw ApiException(problem(text) ?: "Actor answered ${response.code}.")
+ }
+ }
+
+ /** The system camera's one output; Actor validates, strips metadata and binds it to this approval. */
+ suspend fun uploadPhoto(item: ActionItem, context: Context, uri: Uri): String = withContext(Dispatchers.IO) {
+ if (item.deviceKind != "camera") throw ApiException("This is not a photo request.")
+ val token = Session.token() ?: throw ApiException("Sign in to send your photo.")
+ val bytes = context.contentResolver.openInputStream(uri)?.use { stream ->
+ val output = ByteArrayOutputStream()
+ val chunk = ByteArray(64 * 1024)
+ while (true) {
+ val count = stream.read(chunk)
+ if (count < 0) break
+ if (output.size() + count > 10 * 1024 * 1024) throw ApiException("The photo is over 10 MB.")
+ output.write(chunk, 0, count)
+ }
+ output.toByteArray()
+ } ?: throw ApiException("The camera did not return a photo.")
+ if (bytes.isEmpty()) throw ApiException("The camera did not return a photo.")
+ val request = Request.Builder().url("$ACTOR/api/v1/me/approvals/${item.id}/photo")
+ .header("Authorization", "Bearer $token").header("Accept", "application/json")
+ .post(bytes.toRequestBody("image/jpeg".toMediaType())).build()
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (!response.isSuccessful) throw ApiException(problem(text) ?: "Actor answered ${response.code}.")
+ JSONObject(text).optString("media_id").takeIf { it.startsWith("med_") }
+ ?: throw ApiException("Actor did not return a photo id.")
+ }
+ }
+
+ fun photoAnswer(mediaId: String): JSONObject = JSONObject().put("decision", "answer")
+ .put("photo", JSONObject().put("media_id", mediaId))
+
+ fun locationAnswer(location: Location, precise: Boolean): JSONObject {
+ val latitude = if (precise) location.latitude else round(location.latitude * 100.0) / 100.0
+ val longitude = if (precise) location.longitude else round(location.longitude * 100.0) / 100.0
+ return JSONObject().put("decision", "answer").put("location", JSONObject()
+ .put("lat", latitude).put("lng", longitude)
+ .put("accuracy_m", if (precise) location.accuracy.toDouble() else max(1000.0, location.accuracy.toDouble()))
+ .put("precise", precise))
+ }
+
private fun problem(text: String): String? = try {
private fun problem(text: String): String? = try {
(json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject)
(json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject)
?.get("detail")?.toString()?.trim('"')?.takeIf { it.isNotEmpty() }
?.get("detail")?.toString()?.trim('"')?.takeIf { it.isNotEmpty() }
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+package network.openvibe.link.data
+
+import android.content.Context
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Looper
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeout
+import kotlin.coroutines.resume
+
+/** One location reading after the person's tap and Android's permission prompt. No listener stays installed. */
+object DeviceLocation {
+ suspend fun once(context: Context, precise: Boolean): Location = withTimeout(15_000) {
+ val manager = context.getSystemService(LocationManager::class.java)
+ ?: throw ApiException("Location is unavailable on this phone.")
+ val provider = if (precise) LocationManager.GPS_PROVIDER else LocationManager.NETWORK_PROVIDER
+ if (!manager.isProviderEnabled(provider)) throw ApiException("${if (precise) "Precise" else "Approximate"} location is off on this phone.")
+ suspendCancellableCoroutine { continuation ->
+ val listener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ manager.removeUpdates(this)
+ if (continuation.isActive) continuation.resume(location)
+ }
+ }
+ continuation.invokeOnCancellation { manager.removeUpdates(listener) }
+ try {
+ manager.requestSingleUpdate(provider, listener, Looper.getMainLooper())
+ } catch (e: SecurityException) {
+ manager.removeUpdates(listener)
+ continuation.cancel(ApiException("Location permission was not granted."))
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+package network.openvibe.link.data
+
+import android.graphics.BitmapFactory
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.MultiFormatReader
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.HybridBinarizer
+import java.io.File
+
+/** Read a pairing QR from one system-camera photo; no camera permission or live camera stays in this app. */
+object PairPhoto {
+ fun decode(file: File): String? {
+ return try {
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(file.path, bounds)
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
+ var sample = 1
+ while (bounds.outWidth / sample > 1600 || bounds.outHeight / sample > 1600) sample *= 2
+ val bitmap = BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
+ try {
+ val pixels = IntArray(bitmap.width * bitmap.height)
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
+ MultiFormatReader().decode(BinaryBitmap(HybridBinarizer(RGBLuminanceSource(bitmap.width, bitmap.height, pixels)))).text
+ } finally { bitmap.recycle() }
+ } catch (_: Exception) { null }
+ }
+}
@@ -91,6 +91,14 @@ object Store {
@@ -91,6 +91,14 @@ object Store {
prefs.edit().putBoolean("stay_connected", on).apply()
prefs.edit().putBoolean("stay_connected", on).apply()
}
}
+ /** Non-secret Action Center cursor: a local notification is raised at most once per item. */
+ fun noticeCursor(): String = prefs.getString("notice_cursor", null)
+ ?: java.time.Instant.now().minusSeconds(60).toString()
+
+ fun advanceNoticeCursor(at: String) {
+ if (at > (prefs.getString("notice_cursor", null) ?: "")) prefs.edit().putString("notice_cursor", at).apply()
+ }
+
private fun encryptedKey(name: String) = "enc_$name"
private fun encryptedKey(name: String) = "enc_$name"
private fun write(name: String, value: String) {
private fun write(name: String, value: String) {
@@ -14,10 +14,18 @@ import android.os.IBinder
@@ -14,10 +14,18 @@ import android.os.IBinder
import android.os.Looper
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
import network.openvibe.link.MainActivity
import network.openvibe.link.MainActivity
import network.openvibe.link.R
import network.openvibe.link.R
import network.openvibe.link.data.LinkController
import network.openvibe.link.data.LinkController
+import network.openvibe.link.data.Api
import network.openvibe.link.data.Store
import network.openvibe.link.data.Store
+import org.json.JSONObject
/**
/**
* "Stay connected": the phone's one outbound connection to OpenVibe.Bot, held by an opt-in foreground service with a
* "Stay connected": the phone's one outbound connection to OpenVibe.Bot, held by an opt-in foreground service with a
@@ -27,6 +35,9 @@ import network.openvibe.link.data.Store
@@ -27,6 +35,9 @@ import network.openvibe.link.data.Store
class StayConnectedService : Service() {
class StayConnectedService : Service() {
private val main = Handler(Looper.getMainLooper())
private val main = Handler(Looper.getMainLooper())
private var tick: Runnable? = null
private var tick: Runnable? = null
+ private val pollScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var polling: Job? = null
+ private var lastPoll = 0L
override fun onBind(intent: Intent?): IBinder? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -52,6 +63,7 @@ class StayConnectedService : Service() {
@@ -52,6 +63,7 @@ class StayConnectedService : Service() {
override fun onDestroy() {
override fun onDestroy() {
tick?.let { main.removeCallbacks(it) }
tick?.let { main.removeCallbacks(it) }
tick = null
tick = null
+ pollScope.cancel()
if (!Store.stayConnected) LinkController.stop()
if (!Store.stayConnected) LinkController.stop()
super.onDestroy()
super.onDestroy()
}
}
@@ -67,6 +79,10 @@ class StayConnectedService : Service() {
@@ -67,6 +79,10 @@ class StayConnectedService : Service() {
}
}
val manager = getSystemService(NotificationManager::class.java)
val manager = getSystemService(NotificationManager::class.java)
manager?.notify(NOTIFICATION_ID, notification(text))
manager?.notify(NOTIFICATION_ID, notification(text))
+ if (Store.stayConnected && Store.signedIn && System.currentTimeMillis() - lastPoll >= 20_000 && polling?.isActive != true) {
+ lastPoll = System.currentTimeMillis()
+ polling = pollScope.launch { pollNotices() }
+ }
if (LinkController.state != "revoked") main.postDelayed(this, 5_000)
if (LinkController.state != "revoked") main.postDelayed(this, 5_000)
}
}
}
}
@@ -74,6 +90,35 @@ class StayConnectedService : Service() {
@@ -74,6 +90,35 @@ class StayConnectedService : Service() {
main.post(runnable)
main.post(runnable)
}
}
+ private suspend fun pollNotices() {
+ try {
+ if (LinkController.connected) {
+ val id = LinkController.info()?.let { JSONObject(it).optString("device_id") }
+ if (!id.isNullOrBlank()) runCatching { Api.devicePresence(id) }
+ }
+ val actions = Api.actions()
+ val newest = actions.buckets.values.flatten().filter { it.isNotify && it.createdAt != null }
+ .sortedBy { it.createdAt }
+ val cursor = Store.noticeCursor()
+ val manager = getSystemService(NotificationManager::class.java) ?: return
+ for (item in newest.filter { it.createdAt!! > cursor }.takeLast(5)) {
+ val open = PendingIntent.getActivity(this, item.id.hashCode(), Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ val notice = NotificationCompat.Builder(this, AGENTS_CHANNEL)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle(listOfNotNull(item.agent, item.title).joinToString(" · "))
+ .setContentText(item.summary?.removePrefix("notify · ").orEmpty())
+ .setAutoCancel(true).setContentIntent(open)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT).build()
+ try { manager.notify(item.id.hashCode(), notice) } catch (_: SecurityException) { /* notifications are off */ }
+ }
+ newest.lastOrNull()?.createdAt?.let(Store::advanceNoticeCursor)
+ } catch (_: Exception) {
+ // A transient Action Center or session failure is retried on the next poll.
+ }
+ }
+
private fun notification(text: String): Notification {
private fun notification(text: String): Notification {
val open = PendingIntent.getActivity(
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
this, 0, Intent(this, MainActivity::class.java),
@@ -100,6 +145,7 @@ class StayConnectedService : Service() {
@@ -100,6 +145,7 @@ class StayConnectedService : Service() {
const val ACTION_START = "network.openvibe.link.STAY_START"
const val ACTION_START = "network.openvibe.link.STAY_START"
const val ACTION_STOP = "network.openvibe.link.STAY_STOP"
const val ACTION_STOP = "network.openvibe.link.STAY_STOP"
const val CHANNEL = "stay_connected"
const val CHANNEL = "stay_connected"
+ const val AGENTS_CHANNEL = "your_agents"
private const val NOTIFICATION_ID = 1001
private const val NOTIFICATION_ID = 1001
fun ensureChannel(context: Context) {
fun ensureChannel(context: Context) {
@@ -112,6 +158,11 @@ class StayConnectedService : Service() {
@@ -112,6 +158,11 @@ class StayConnectedService : Service() {
},
},
)
)
}
}
+ if (manager.getNotificationChannel(AGENTS_CHANNEL) == null) {
+ manager.createNotificationChannel(NotificationChannel(AGENTS_CHANNEL, "Your agents", NotificationManager.IMPORTANCE_DEFAULT).apply {
+ description = "Notifications your agents asked to show on this phone"
+ })
+ }
}
}
fun start(context: Context) {
fun start(context: Context) {
@@ -1,5 +1,9 @@
@@ -1,5 +1,9 @@
package network.openvibe.link.ui
package network.openvibe.link.ui
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,11 +38,19 @@ import androidx.compose.ui.platform.LocalContext
@@ -34,11 +38,19 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentActivity
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import kotlinx.coroutines.launch
import kotlinx.coroutines.launch
+import org.json.JSONObject
+import java.io.File
+import java.util.UUID
import network.openvibe.link.approval.ApprovalCategory
import network.openvibe.link.approval.ApprovalCategory
import network.openvibe.link.approval.ApprovalRules
import network.openvibe.link.approval.ApprovalRules
import network.openvibe.link.approval.Biometrics
import network.openvibe.link.approval.Biometrics
import network.openvibe.link.data.ActionItem
import network.openvibe.link.data.ActionItem
+import network.openvibe.link.data.Api
+import network.openvibe.link.data.DeviceLocation
+import network.openvibe.link.data.LinkController
import network.openvibe.link.data.Store
import network.openvibe.link.data.Store
/** Each category's tone and icon: money amber, deleting red, passwords violet, a new recipient blue. */
/** Each category's tone and icon: money amber, deleting red, passwords violet, a new recipient blue. */
@@ -69,6 +81,59 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {
@@ -69,6 +81,59 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {
val activity = context as? FragmentActivity
val activity = context as? FragmentActivity
val scope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
var working by remember { mutableStateOf<String?>(null) }
var working by remember { mutableStateOf<String?>(null) }
+ var capture by remember { mutableStateOf<Pair<ActionItem, File>?>(null) }
+ var locationRequest by remember { mutableStateOf<Pair<ActionItem, Boolean>?>(null) }
+
+ val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { saved ->
+ val pending = capture
+ capture = null
+ if (pending != null) {
+ val (item, file) = pending
+ if (saved) {
+ working = item.id
+ scope.launch {
+ try {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.devicephotos", file)
+ val mediaId = Api.uploadPhoto(item, context, uri)
+ Api.answerDevice(item, Api.photoAnswer(mediaId))
+ ActionsRepo.refresh()
+ Messages.show("Your photo was shared once.")
+ } catch (e: Exception) {
+ Messages.show(e.message ?: "The photo was not shared.")
+ } finally {
+ file.delete()
+ working = null
+ }
+ }
+ } else file.delete()
+ }
+ }
+ val locationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
+ val pending = locationRequest
+ locationRequest = null
+ if (pending != null) {
+ val (item, precise) = pending
+ val coarse = grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val fine = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ if (!coarse || (precise && !fine)) {
+ Messages.show(if (precise) "Precise location was not allowed. You can choose approximate sharing." else "Location was not allowed.")
+ } else {
+ working = item.id
+ scope.launch {
+ try {
+ val point = DeviceLocation.once(context, precise)
+ Api.answerDevice(item, Api.locationAnswer(point, precise))
+ ActionsRepo.refresh()
+ Messages.show("Your location was shared once.")
+ } catch (e: Exception) {
+ Messages.show(e.message ?: "The location was not shared.")
+ } finally { working = null }
+ }
+ }
+ }
+ }
LaunchedEffect(Store.signedIn) {
LaunchedEffect(Store.signedIn) {
if (preview == null && Store.signedIn) ActionsRepo.refresh()
if (preview == null && Store.signedIn) ActionsRepo.refresh()
@@ -111,6 +176,7 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {
@@ -111,6 +176,7 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {
items(waiting, key = { it.service + it.id }) { item ->
items(waiting, key = { it.service + it.id }) { item ->
val category = ApprovalRules.classify(item.category, item.kind, item.title, item.summary)
val category = ApprovalRules.classify(item.category, item.kind, item.title, item.summary)
+ val deviceKind = item.deviceKind
val decide: (Boolean) -> Unit = { approve ->
val decide: (Boolean) -> Unit = { approve ->
val send = {
val send = {
working = item.id
working = item.id
@@ -138,20 +204,59 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {
@@ -138,20 +204,59 @@ fun ApprovalsScreen(preview: List<ActionItem>? = null) {
}
}
LinkCard(accent = if (category.needsBiometrics) toneColor(category.tone()) else null) {
LinkCard(accent = if (category.needsBiometrics) toneColor(category.tone()) else null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- ToneChip(category.label, category.tone(), category.icon())
+ ToneChip(if (deviceKind == "camera") "Photo request" else if (deviceKind == "location") "Location request" else category.label,
+ if (deviceKind != null) Tone.Info else category.tone(), category.icon())
if (category.needsBiometrics) {
if (category.needsBiometrics) {
Icon(Icons.Outlined.Fingerprint, contentDescription = "Asks for your fingerprint or face", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
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)
Text(item.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
- item.summary?.takeIf { it.isNotBlank() && it != item.title }?.let {
+ (if (deviceKind != null) item.deviceWhy else item.summary)?.takeIf { it.isNotBlank() && it != item.title }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
val who = listOfNotNull(item.agent, item.createdAt?.let { shortTime(it) }).joinToString(" · ")
val who = listOfNotNull(item.agent, item.createdAt?.let { shortTime(it) }).joinToString(" · ")
if (who.isNotEmpty()) {
if (who.isNotEmpty()) {
Text(who, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(who, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
- Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ if (deviceKind != null && preview == null && !LinkController.connected) {
+ Text("This phone is offline. Connect it before answering; you can use the approval page in a browser.",
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
+ }
+ if (deviceKind == "camera") {
+ Button(enabled = working == null && (preview != null || LinkController.connected),
+ onClick = {
+ val folder = File(context.cacheDir, "device-photos").apply { mkdirs() }
+ val file = File(folder, "${UUID.randomUUID()}.jpg")
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.devicephotos", file)
+ capture = item to file
+ camera.launch(uri)
+ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Take a photo") }
+ } else if (deviceKind == "location") {
+ Button(enabled = working == null && (preview != null || LinkController.connected),
+ onClick = {
+ locationRequest = item to false
+ locationPermission.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION))
+ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Share where I am once") }
+ if (item.preciseRequested) {
+ OutlinedButton(enabled = working == null && (preview != null || LinkController.connected),
+ onClick = {
+ locationRequest = item to true
+ locationPermission.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION))
+ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Share precise location once") }
+ }
+ }
+ if (deviceKind != null) {
+ OutlinedButton(enabled = working == null, onClick = {
+ working = item.id
+ scope.launch {
+ try {
+ Api.answerDevice(item, JSONObject().put("decision", "decline"))
+ ActionsRepo.refresh()
+ } catch (e: Exception) { Messages.show(e.message ?: "The answer was not sent.") }
+ finally { working = null }
+ }
+ }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text("Decline") }
+ } else Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
Button(
enabled = working == null,
enabled = working == null,
onClick = { decide(true) },
onClick = { decide(true) },
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
package network.openvibe.link.ui
package network.openvibe.link.ui
+import android.os.Build
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.foundation.background
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement
@@ -13,9 +14,6 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
@@ -13,9 +14,6 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.Icon
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Column
@@ -40,12 +38,13 @@ import androidx.compose.runtime.setValue
@@ -40,12 +38,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
-import com.journeyapps.barcodescanner.ScanContract
-import com.journeyapps.barcodescanner.ScanOptions
+import androidx.core.content.FileProvider
+import java.io.File
+import java.util.UUID
import kotlinx.coroutines.launch
import kotlinx.coroutines.launch
import network.openvibe.link.data.Api
import network.openvibe.link.data.Api
import network.openvibe.link.data.PairLinks
import network.openvibe.link.data.PairLinks
+import network.openvibe.link.data.PairPhoto
import network.openvibe.link.data.Store
import network.openvibe.link.data.Store
/**
/**
@@ -60,6 +59,7 @@ fun PairScreen(initialCode: String?) {
@@ -60,6 +59,7 @@ fun PairScreen(initialCode: String?) {
var pairing by rememberSaveable { mutableStateOf<String?>(null) }
var pairing by rememberSaveable { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
+ var scanFile by remember { mutableStateOf<File?>(null) }
LaunchedEffect(initialCode) {
LaunchedEffect(initialCode) {
if (!initialCode.isNullOrBlank()) code = initialCode
if (!initialCode.isNullOrBlank()) code = initialCode
@@ -71,22 +71,24 @@ fun PairScreen(initialCode: String?) {
@@ -71,22 +71,24 @@ fun PairScreen(initialCode: String?) {
Incoming.pairRefused = null
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
+ val scanner = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { saved ->
+ val file = scanFile
+ scanFile = null
+ if (saved && file != null) {
+ val link = PairLinks.codeFromScan(PairPhoto.decode(file))
+ when {
+ link?.refusedServer != null -> {
+ error = "That QR points at ${link.refusedServer}, not OpenVibe Network."
+ Messages.show(error)
+ }
+ link?.code != null -> {
+ code = link.code
+ pairing = link.pairing
+ }
+ else -> Messages.show("The photo has no OpenVibe pairing QR. Try again or type the code.")
}
}
- 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.")
+ file?.delete()
}
}
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp)) {
@@ -103,16 +105,15 @@ fun PairScreen(initialCode: String?) {
@@ -103,16 +105,15 @@ fun PairScreen(initialCode: String?) {
}
}
Button(
Button(
onClick = {
onClick = {
- if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
- launchScanner(scanner)
- } else {
- askCamera.launch(Manifest.permission.CAMERA)
- }
+ val folder = File(context.cacheDir, "pair-photos").apply { mkdirs() }
+ val file = File(folder, "${UUID.randomUUID()}.jpg")
+ scanFile = file
+ scanner.launch(FileProvider.getUriForFile(context, "${context.packageName}.devicephotos", file))
},
},
modifier = Modifier.fillMaxWidth().padding(top = 16.dp).heightIn(min = 52.dp),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp).heightIn(min = 52.dp),
) {
) {
Icon(Icons.Outlined.QrCodeScanner, contentDescription = null, modifier = Modifier.padding(end = 10.dp))
Icon(Icons.Outlined.QrCodeScanner, contentDescription = null, modifier = Modifier.padding(end = 10.dp))
- Text("Scan the QR", fontWeight = FontWeight.Bold)
+ Text("Photograph the QR", fontWeight = FontWeight.Bold)
}
}
Text(
Text(
"Or type the code",
"Or type the code",
@@ -169,16 +170,6 @@ fun PairScreen(initialCode: String?) {
@@ -169,16 +170,6 @@ fun PairScreen(initialCode: String?) {
}
}
}
}
-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. */
/** One numbered step of pairing. */
@Composable
@Composable
private fun PairStep(n: Int, text: String) {
private fun PairStep(n: Int, text: String) {
@@ -8,6 +8,8 @@ import network.openvibe.link.data.ActionsDoc
@@ -8,6 +8,8 @@ import network.openvibe.link.data.ActionsDoc
import network.openvibe.link.data.Api
import network.openvibe.link.data.Api
import network.openvibe.link.data.Session
import network.openvibe.link.data.Session
import network.openvibe.link.data.Store
import network.openvibe.link.data.Store
+import network.openvibe.link.data.LinkController
+import org.json.JSONObject
/** Intents the activity received, for the Compose tree to read (a deep link can arrive while it is already drawn). */
/** Intents the activity received, for the Compose tree to read (a deep link can arrive while it is already drawn). */
object Incoming {
object Incoming {
@@ -50,6 +52,10 @@ object ActionsRepo {
@@ -50,6 +52,10 @@ object ActionsRepo {
loading = true
loading = true
error = null
error = null
try {
try {
+ if (LinkController.connected) {
+ val id = LinkController.info()?.let { JSONObject(it).optString("device_id") }
+ if (!id.isNullOrBlank()) runCatching { Api.devicePresence(id) }
+ }
doc = Api.actions()
doc = Api.actions()
} catch (e: Exception) {
} catch (e: Exception) {
error = e.message ?: "Could not read your activity."
error = e.message ?: "Could not read your activity."
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths xmlns:android="http://schemas.android.com/apk/res/android">
+ <cache-path name="device-photos" path="device-photos/" />
+ <cache-path name="pair-photos" path="pair-photos/" />
+</paths>
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+package network.openvibe.link
+
+import network.openvibe.link.data.ActionItem
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DeviceActionsTest {
+ @Test fun cameraAndLocationAreNotOrdinaryApprovals() {
+ val photo = ActionItem(service = "actor", id = "apr_camera", kind = "approval",
+ summary = "device.camera · Read the sign before suggesting a route")
+ val where = ActionItem(service = "actor", id = "apr_location", kind = "approval",
+ summary = "device.location.precise · Find a nearby open pharmacy")
+ assertEquals("camera", photo.deviceKind)
+ assertEquals("Read the sign before suggesting a route", photo.deviceWhy)
+ assertEquals("location", where.deviceKind)
+ assertTrue(where.preciseRequested)
+ assertFalse(photo.preciseRequested)
+ }
+
+ @Test fun onlyFlaggedActorItemsArePhoneNotifications() {
+ val notice = ActionItem(service = "actor", id = "ntf_1", kind = "agent_task",
+ summary = "notify · The report is ready")
+ assertTrue(notice.isNotify)
+ assertFalse(notice.copy(id = "tsk_1").isNotify)
+ assertFalse(notice.copy(service = "chat").isNotify)
+ }
+}
@@ -18,6 +18,9 @@ import network.openvibe.link.ui.ApprovalsScreen
@@ -18,6 +18,9 @@ import network.openvibe.link.ui.ApprovalsScreen
import network.openvibe.link.ui.LinkTheme
import network.openvibe.link.ui.LinkTheme
import network.openvibe.link.ui.PairScreen
import network.openvibe.link.ui.PairScreen
import network.openvibe.link.ui.PhoneScreen
import network.openvibe.link.ui.PhoneScreen
+import java.util.TimeZone
+import org.junit.After
+import org.junit.Before
import org.junit.Rule
import org.junit.Rule
import org.junit.Test
import org.junit.Test
@@ -29,6 +32,16 @@ import org.junit.Test
@@ -29,6 +32,16 @@ import org.junit.Test
class ScreensSnapshotTest {
class ScreensSnapshotTest {
@get:Rule
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5, theme = "android:Theme.Material.NoActionBar")
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5, theme = "android:Theme.Material.NoActionBar")
+ private lateinit var previousTimeZone: TimeZone
+
+ @Before fun useSnapshotTimeZone() {
+ previousTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
+ }
+
+ @After fun restoreTimeZone() {
+ TimeZone.setDefault(previousTimeZone)
+ }
/** The pair screen asks for a scanner result; under Paparazzi there is no activity, so a registry that never launches. */
/** 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 {
private val noResults = object : ActivityResultRegistryOwner {
@@ -48,6 +61,12 @@ class ScreensSnapshotTest {
@@ -48,6 +61,12 @@ class ScreensSnapshotTest {
@Test fun approvalsDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_APPROVALS) }
@Test fun approvalsDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_APPROVALS) }
@Test fun approvalsLight() = shot(false) { 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 approvalsEmptyDark() = shot(true) { ApprovalsScreen(preview = emptyList()) }
+ @Test fun cameraDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_CAMERA) }
+ @Test fun cameraLight() = shot(false) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_CAMERA) }
+ @Test fun locationDark() = shot(true) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_LOCATION) }
+ @Test fun locationLight() = shot(false) { ApprovalsScreen(preview = PreviewActivity.SAMPLE_LOCATION) }
+ @Test fun notifyDark() = shot(true) { ActivityScreen(preview = PreviewActivity.SAMPLE_NOTIFY) }
+ @Test fun notifyLight() = shot(false) { ActivityScreen(preview = PreviewActivity.SAMPLE_NOTIFY) }
@Test fun activityDark() = shot(true) { ActivityScreen(preview = PreviewActivity.SAMPLE_ACTIVITY) }
@Test fun activityDark() = shot(true) { ActivityScreen(preview = PreviewActivity.SAMPLE_ACTIVITY) }
@Test fun activityLight() = shot(false) { 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 phoneDark() = shot(true) { PhoneScreen(previewInfo = PreviewActivity.SAMPLE_DEVICE, preview = true) }
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png 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_cameraDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraDark.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_cameraLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraLight.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_locationDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationDark.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_locationLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationLight.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_notifyDark.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyDark.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_notifyLight.png differ
Binary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyLight.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png differ
Binary files a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png differ
@@ -17,7 +17,7 @@ adb shell settings put global animator_duration_scale 0 || true
@@ -17,7 +17,7 @@ adb shell settings put global animator_duration_scale 0 || true
apk=$(find android/app/build/outputs/apk/debug -name "*.apk" | head -n 1)
apk=$(find android/app/build/outputs/apk/debug -name "*.apk" | head -n 1)
adb install -r "$apk"
adb install -r "$apk"
-for screen in pair approvals activity phone; do
+for screen in pair approvals camera location notify activity phone; do
adb shell am force-stop network.openvibe.link || true
adb shell am force-stop network.openvibe.link || true
adb shell am start -W -n network.openvibe.link/.PreviewActivity --es screen "$screen" >/dev/null
adb shell am start -W -n network.openvibe.link/.PreviewActivity --es screen "$screen" >/dev/null
sleep 2
sleep 2
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
pluginManagement {
pluginManagement {
repositories {
repositories {
+ mavenLocal()
google()
google()
mavenCentral()
mavenCentral()
gradlePluginPortal()
gradlePluginPortal()
@@ -9,6 +10,7 @@ pluginManagement {
@@ -9,6 +10,7 @@ pluginManagement {
dependencyResolutionManagement {
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
repositories {
+ mavenLocal()
google()
google()
mavenCentral()
mavenCentral()
}
}