openvibers/openvibe.link Commits

Link: answer HO12 camera and location requests on the phone (#138)

OP OpenVibers committed 5 days ago · cbfbd1e5b4 · 29 files, +479 −53

* Link: answer device requests with a person tap

* Fetch pinned Gradle through verified release asset

* Install pinned Go from verified archive in Android CI

* Expose gomobile helper on Android CI path

* Try direct Maven Central host for Android build

* Import Android Build model for pairing

* Cache verified Kotlin compiler for Android CI

* Run Android CI on stable Ubuntu image

* Stabilize screen snapshots in Los Angeles time

* Commit verified HO12 screen baselines

* Use stable Ubuntu runners for Node CI

* Fetch verified ESP32 CI inputs through release assets

* Stage complete pinned ESP32 toolchain in CI

* Stage pinned ESP32 platform archive in CI

Changes

ddiff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
iindex d15782e..e789f1b 100644
---- a/.github/workflows/android.yml
++++ b/.github/workflows/android.yml
@@@ -12,7 +12,7 @@ permissions:
jobs:
apk:
name: Go core, unit tests and the APK
-- runs-on: ubuntu-latest
++ runs-on: ubuntu-24.04
permissions:
contents: write # the APK is attached to the release on a tag
steps:
@@@ -21,15 +21,37 @@ jobs:
with:
distribution: temurin
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
with:
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: |
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 80345af..149289e 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -76,7 +76,7 @@ jobs:
build:
name: cross-compile
-- runs-on: ubuntu-latest
++ runs-on: ubuntu-24.04
steps:
- 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.04
steps:
- 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.04
steps:
- 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
esp32-compile:
name: esp32 compile (arduino-cli)
-- runs-on: ubuntu-latest
++ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: arduino/setup-arduino-cli@v2
@@@ -232,6 +239,24 @@ jobs:
arduino-cli config init
arduino-cli config add board_manager.additional_urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
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 lib install "[email protected]"
arduino-cli lib install "[email protected]"
ddiff --git a/android/app/src/debug/java/network/openvibe/link/PreviewActivity.kt b/android/app/src/debug/java/network/openvibe/link/PreviewActivity.kt
iindex f7f95e2..abed8a8 100644
---- a/android/app/src/debug/java/network/openvibe/link/PreviewActivity.kt
++++ b/android/app/src/debug/java/network/openvibe/link/PreviewActivity.kt
@@@ -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")
ddiff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
iindex 6908f77..5daa0ab 100644
---- a/android/app/src/main/AndroidManifest.xml
++++ b/android/app/src/main/AndroidManifest.xml
@@@ -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>
++
<activity
android:name=".MainActivity"
android:exported="true"
ddiff --git a/android/app/src/main/java/network/openvibe/link/data/Api.kt b/android/app/src/main/java/network/openvibe/link/data/Api.kt
iindex 0796ec2..6c38eef 100644
---- a/android/app/src/main/java/network/openvibe/link/data/Api.kt
++++ b/android/app/src/main/java/network/openvibe/link/data/Api.kt
@@@ -1,5 +1,8 @@
package network.openvibe.link.data
++import android.content.Context
++import android.location.Location
++import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
@@@ -9,6 +12,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
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
/** 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.network
const 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() }
ddiff --git a/android/app/src/main/java/network/openvibe/link/data/DeviceLocation.kt b/android/app/src/main/java/network/openvibe/link/data/DeviceLocation.kt
nnew file mode 100644
iindex 0000000..b4b6b46
---- /dev/null
++++ b/android/app/src/main/java/network/openvibe/link/data/DeviceLocation.kt
@@@ -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."))
++ }
++ }
++ }
++}
ddiff --git a/android/app/src/main/java/network/openvibe/link/data/PairPhoto.kt b/android/app/src/main/java/network/openvibe/link/data/PairPhoto.kt
nnew file mode 100644
iindex 0000000..b352be4
---- /dev/null
++++ b/android/app/src/main/java/network/openvibe/link/data/PairPhoto.kt
@@@ -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 }
++ }
++}
ddiff --git a/android/app/src/main/java/network/openvibe/link/data/Store.kt b/android/app/src/main/java/network/openvibe/link/data/Store.kt
iindex 95886a1..8809faa 100644
---- a/android/app/src/main/java/network/openvibe/link/data/Store.kt
++++ b/android/app/src/main/java/network/openvibe/link/data/Store.kt
@@@ -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) {
ddiff --git a/android/app/src/main/java/network/openvibe/link/service/StayConnectedService.kt b/android/app/src/main/java/network/openvibe/link/service/StayConnectedService.kt
iindex 8f94100..5f452b6 100644
---- a/android/app/src/main/java/network/openvibe/link/service/StayConnectedService.kt
++++ b/android/app/src/main/java/network/openvibe/link/service/StayConnectedService.kt
@@@ -14,10 +14,18 @@ import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
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.R
import network.openvibe.link.data.LinkController
++import network.openvibe.link.data.Api
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
@@@ -27,6 +35,9 @@ import network.openvibe.link.data.Store
class 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 = 0L
override 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 = 1001
fun 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) {
ddiff --git a/android/app/src/main/java/network/openvibe/link/ui/ApprovalsScreen.kt b/android/app/src/main/java/network/openvibe/link/ui/ApprovalsScreen.kt
iindex 0c6c770..652e795 100644
---- a/android/app/src/main/java/network/openvibe/link/ui/ApprovalsScreen.kt
++++ b/android/app/src/main/java/network/openvibe/link/ui/ApprovalsScreen.kt
@@@ -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.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@@ -34,11 +38,19 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
++import androidx.core.content.ContextCompat
++import androidx.core.content.FileProvider
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.ApprovalRules
import network.openvibe.link.approval.Biometrics
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
/** 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? FragmentActivity
val 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.deviceKind
val 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) },
ddiff --git a/android/app/src/main/java/network/openvibe/link/ui/PairScreen.kt b/android/app/src/main/java/network/openvibe/link/ui/PairScreen.kt
iindex 1b08350..d28ea41 100644
---- a/android/app/src/main/java/network/openvibe/link/ui/PairScreen.kt
++++ b/android/app/src/main/java/network/openvibe/link/ui/PairScreen.kt
@@@ -1,5 +1,6 @@
package network.openvibe.link.ui
++import android.os.Build
import androidx.compose.material3.FilledTonalButton
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@@ -13,9 +14,6 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
--import android.Manifest
--import android.content.pm.PackageManager
--import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
@@@ -40,12 +38,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
--import androidx.core.content.ContextCompat
--import com.journeyapps.barcodescanner.ScanContract
--import com.journeyapps.barcodescanner.ScanOptions
++import androidx.core.content.FileProvider
++import java.io.File
++import java.util.UUID
import kotlinx.coroutines.launch
import network.openvibe.link.data.Api
import network.openvibe.link.data.PairLinks
++import network.openvibe.link.data.PairPhoto
import 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. */
@Composable
private fun PairStep(n: Int, text: String) {
ddiff --git a/android/app/src/main/java/network/openvibe/link/ui/State.kt b/android/app/src/main/java/network/openvibe/link/ui/State.kt
iindex ed1d30f..48d39fb 100644
---- a/android/app/src/main/java/network/openvibe/link/ui/State.kt
++++ b/android/app/src/main/java/network/openvibe/link/ui/State.kt
@@@ -8,6 +8,8 @@ import network.openvibe.link.data.ActionsDoc
import network.openvibe.link.data.Api
import network.openvibe.link.data.Session
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). */
object Incoming {
@@@ -50,6 +52,10 @@ object ActionsRepo {
loading = true
error = null
try {
++ 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."
ddiff --git a/android/app/src/main/res/xml/device_photo_paths.xml b/android/app/src/main/res/xml/device_photo_paths.xml
nnew file mode 100644
iindex 0000000..3a70ebf
---- /dev/null
++++ b/android/app/src/main/res/xml/device_photo_paths.xml
@@@ -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>
ddiff --git a/android/app/src/test/java/network/openvibe/link/DeviceActionsTest.kt b/android/app/src/test/java/network/openvibe/link/DeviceActionsTest.kt
nnew file mode 100644
iindex 0000000..3899eca
---- /dev/null
++++ b/android/app/src/test/java/network/openvibe/link/DeviceActionsTest.kt
@@@ -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)
++ }
++}
ddiff --git a/android/app/src/test/java/network/openvibe/link/ScreensSnapshotTest.kt b/android/app/src/test/java/network/openvibe/link/ScreensSnapshotTest.kt
iindex 9f7cd9a..5b0cde9 100644
---- a/android/app/src/test/java/network/openvibe/link/ScreensSnapshotTest.kt
++++ b/android/app/src/test/java/network/openvibe/link/ScreensSnapshotTest.kt
@@@ -18,6 +18,9 @@ import network.openvibe.link.ui.ApprovalsScreen
import network.openvibe.link.ui.LinkTheme
import network.openvibe.link.ui.PairScreen
import network.openvibe.link.ui.PhoneScreen
++import java.util.TimeZone
++import org.junit.After
++import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@@ -29,6 +32,16 @@ import org.junit.Test
class ScreensSnapshotTest {
@get:Rule
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. */
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) }
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityDark.png
iindex 85f642f..7745120 100644
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 differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_activityLight.png
iindex 297f478..f037d4c 100644
BBinary 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
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsDark.png
iindex 0818362..f6b3016 100644
BBinary 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
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_approvalsLight.png
iindex a8ef916..eef5321 100644
BBinary 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
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraDark.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraDark.png
nnew file mode 100644
iindex 0000000..b76e491
BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraDark.png differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraLight.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraLight.png
nnew file mode 100644
iindex 0000000..249fc06
BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_cameraLight.png differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationDark.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationDark.png
nnew file mode 100644
iindex 0000000..322358a
BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationDark.png differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationLight.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationLight.png
nnew file mode 100644
iindex 0000000..fdedc38
BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_locationLight.png differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyDark.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyDark.png
nnew file mode 100644
iindex 0000000..83895d6
BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyDark.png differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyLight.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyLight.png
nnew file mode 100644
iindex 0000000..4c8dee6
BBinary files /dev/null and b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_notifyLight.png differ
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairDark.png
iindex f03878a..3a5a888 100644
BBinary 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
ddiff --git a/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png b/android/app/src/test/snapshots/images/network.openvibe.link_ScreensSnapshotTest_pairLight.png
iindex 0375d6d..f58575a 100644
BBinary 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
ddiff --git a/android/scripts/screenshots.sh b/android/scripts/screenshots.sh
iindex 0260551..fb78391 100644
---- a/android/scripts/screenshots.sh
++++ b/android/scripts/screenshots.sh
@@@ -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)
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 start -W -n network.openvibe.link/.PreviewActivity --es screen "$screen" >/dev/null
sleep 2
ddiff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
iindex 2fc6bec..bb75e0c 100644
---- a/android/settings.gradle.kts
++++ b/android/settings.gradle.kts
@@@ -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()
}