Add QR code pairing flow for Warpnet fat-node connection#5
Conversation
Scan a fat-node AuthNodeInfo QR, verify the Noise handshake against the advertised peer ID, submit the raw pairing JSON on /private/post/admin/pair/0.0.0, and persist the Identity plus pinned peer ID in EncryptedSharedPreferences. MainActivity routes to the scanner on startup when no paired node exists.
CI build failure (
|
Drop gradle/verification-metadata.xml and the two build.gradle workarounds that only existed to dance around missing hashes (disabled androidTest still needs to stay for the config-cache bug; trimmed the rationale).
CI build failure (
|
security-crypto 1.1.0 still pins tink-android:1.8.0, whose classes collide with the unified tink:1.17+ pulled in by ML Kit. Exclude the old artifact so the newer one is the single provider.
CI build failure (
|
R8 in the last CI build failed with "Missing class com.google.crypto.tink.integration.android.AndroidKeysetManager" because the previous commit excluded tink-android from security-crypto. AndroidKeysetManager only ships in tink-android:1.8.0; the unified tink:1.17.0 JVM artifact pulled transitively by ML Kit does not contain the Android-Keystore integration classes that EncryptedSharedPreferences relies on. Reverse the exclusion: keep tink-android (which security-crypto needs) and drop the duplicate unified tink from the ML Kit classpath instead. https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV
CI build failure (
|
The per-dependency exclusion on ML Kit did not stop tink:1.17.0 from landing on the classpath — another transitive dep pulls it as well — so R8 kept hitting ~200 duplicate classes against tink-android:1.8.0. Switch to a configurations-wide exclude of com.google.crypto.tink:tink so the unified JVM artifact is dropped regardless of which dependency brings it in. tink-android is a distinct module name, so it keeps shipping AndroidKeysetManager for EncryptedSharedPreferences. https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV
CI build failure (
|
The tink duplicate-class issue is resolved, but licensee now fails because the Play Services and ML Kit artifacts pulled in for the QR scanner publish under Google's Android SDK Terms of Service and ML Kit Terms of Service rather than an SPDX license. Add the two canonical URLs to the allowlist so the licenseeAndroidBlueDebug task passes. https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV
There was a problem hiding this comment.
Pull request overview
Adds a QR-code pairing flow to connect the Android client to a Warpnet “fat node”, including camera-based QR scanning, validation, pairing handshake, and encrypted persistence of pairing credentials.
Changes:
- Introduces pairing DTOs + validation, pairing orchestration, and encrypted persistence for paired-node credentials.
- Adds CameraX + ML Kit QR scanning UI via a new
PairingActivityand routes first-run users there fromMainActivity. - Extends the transport layer with configurable
network, multi-address dial attempts (connectAny), and a pairing stream (pair).
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
warpnet-transport/src/main/kotlin/site/warpnet/transport/dto/AuthNodeInfo.kt |
Adds Moshi wire DTOs for QR pairing payloads. |
warpnet-transport/src/main/kotlin/site/warpnet/transport/WarpnetConfig.kt |
Adds configurable network to transport initialization config. |
warpnet-transport/src/main/kotlin/site/warpnet/transport/WarpnetClient.kt |
Uses configured network; adds connectAny() and pair() APIs. |
version |
Bumps app version. |
gradle/libs.versions.toml |
Adds CameraX, ML Kit barcode scanning, and Security Crypto versions/aliases. |
app/src/main/res/values/warpnet_pair.xml |
Adds pairing-specific UI strings. |
app/src/main/res/layout/activity_pairing.xml |
Adds new pairing screen layout (camera preview + confirmation/progress panel). |
app/src/main/java/com/keylesspalace/tusky/di/WarpnetModule.kt |
Adds DI provider for PairedNodeStore. |
app/src/main/java/com/keylesspalace/tusky/components/pairing/QrCodeAnalyzer.kt |
Implements CameraX analyzer using on-device ML Kit QR decoding. |
app/src/main/java/com/keylesspalace/tusky/components/pairing/PairingCoordinator.kt |
Orchestrates init → dial → pair → persist flow and maps failures to outcomes. |
app/src/main/java/com/keylesspalace/tusky/components/pairing/PairingActivity.kt |
Implements end-to-end pairing UI flow and integrates validator + coordinator. |
app/src/main/java/com/keylesspalace/tusky/components/pairing/PairedNodeStore.kt |
Adds keystore-backed encrypted storage for paired node credentials. |
app/src/main/java/com/keylesspalace/tusky/components/pairing/PairedNode.kt |
Defines persisted paired-node model + mapping from QR DTO. |
app/src/main/java/com/keylesspalace/tusky/components/pairing/AuthNodeInfoValidator.kt |
Validates scanned QR JSON and required fields before pairing. |
app/src/main/java/com/keylesspalace/tusky/MainActivity.kt |
Redirects to pairing when no stored pairing exists. |
app/src/main/AndroidManifest.xml |
Declares camera permission/feature and registers PairingActivity. |
app/build.gradle |
Adds dependencies + license allowances; excludes duplicate Tink artifact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| required — the scanner activity falls back to a manual JSON paste | ||
| when the user denies the permission or no camera is present. --> |
There was a problem hiding this comment.
The manifest comment says the pairing scanner “falls back to a manual JSON paste when the user denies the permission or no camera is present”, but PairingActivity currently just shows a fatal message on denial and doesn’t implement any manual-paste fallback (and also doesn’t handle the no-camera case). Please either implement the described fallback, or update/remove this comment so it matches actual behavior.
| required — the scanner activity falls back to a manual JSON paste | |
| when the user denies the permission or no camera is present. --> | |
| required, so the app remains installable on devices without a | |
| camera. --> |
| provider.unbindAll() | ||
| provider.bindToLifecycle( | ||
| this, | ||
| CameraSelector.DEFAULT_BACK_CAMERA, | ||
| preview, | ||
| imageAnalysis, | ||
| ) |
There was a problem hiding this comment.
startCamera() assumes a back camera is available and bindToLifecycle() will succeed. Because the manifest declares camera hardware as not required, this can crash on devices without a camera (or without a back camera) / when CameraX can’t bind. Consider checking provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) and wrapping bindToLifecycle() in try/catch to show a user-facing error instead of crashing.
| override fun onDestroy() { | ||
| super.onDestroy() | ||
| analyzer?.close() | ||
| cameraProvider?.unbindAll() | ||
| } |
There was a problem hiding this comment.
cameraExecutor is created via Executors.newSingleThreadExecutor() but never shut down. This leaves a thread running after the activity is destroyed. Consider using an ExecutorService and calling shutdown()/shutdownNow() in onDestroy (or using a lifecycle-aware executor).
| kotlinx.coroutines.withTimeoutOrNull(DIAL_TIMEOUT_MILLIS) { | ||
| mutex.withLock { | ||
| if (_state.value == ConnectionState.Uninitialised) { | ||
| throw WarpnetException.NotInitialised() | ||
| } | ||
| _state.value = ConnectionState.Connecting | ||
| binding.connect(addr) | ||
| } | ||
| } ?: "timed out after ${DIAL_TIMEOUT_MILLIS}ms" | ||
| }.getOrElse { it.message ?: it.toString() } | ||
| if (err.isEmpty()) { | ||
| _state.value = ConnectionState.Connected | ||
| return@withContext addr | ||
| } |
There was a problem hiding this comment.
connectAny()’s per-address timeout is unlikely to work as documented because binding.connect(...) is a blocking (non-suspending) call; coroutine cancellation from withTimeoutOrNull won’t interrupt it, so callers may still wait the full native dial duration. Also, _state is updated to Connected outside the mutex, and failed attempts leave the state at Connecting. Consider moving state transitions inside the mutex, resetting state to Disconnected on per-address failure, and enforcing timeouts by running connect() on an interruptible worker / adding native cancellation support.
| val msg = e.message.orEmpty() | ||
| if (msg.contains("peer id mismatch", ignoreCase = true)) { | ||
| PairingOutcome.PeerIdMismatch(paired.pinnedPeerId, msg) | ||
| } else { |
There was a problem hiding this comment.
PairingOutcome.PeerIdMismatch has fields (expected, actual) but the constructor call passes the transport error message as the “actual” value. This is misleading for callers and future UI (it’s not the actual peer ID). Consider either parsing/extracting the actual peer ID (if available) or renaming the field to something like actualOrError/errorMessage.
| // If no fat-node pairing yet, bounce to the QR scanner. The returning | ||
| // PairingActivity clears the task, so this branch only fires once per | ||
| // cold install (or after "Forget this node"). | ||
| if (pairedNodeStore.load() == null) { | ||
| startActivity(Intent(this, com.keylesspalace.tusky.components.pairing.PairingActivity::class.java)) | ||
| finish() | ||
| return |
There was a problem hiding this comment.
pairedNodeStore.load() performs an EncryptedSharedPreferences read plus JSON parsing on the main thread during MainActivity startup. To avoid cold-start jank/StrictMode violations, consider doing this check off the UI thread (e.g., prefetch in a splash/launcher, or load asynchronously and only redirect once the result is known).
Three small fixes from the Copilot review on PR #5: - AndroidManifest: drop the stale comment claiming a manual-JSON-paste fallback on camera denial; no such fallback exists, so describe only why camera hardware is not required. - PairingActivity: shut down the single-thread cameraExecutor in onDestroy so it doesn't leak a thread past the activity lifecycle. Widen its type to ExecutorService to expose shutdown(). - PairingCoordinator: rename PeerIdMismatch.actual to errorMessage. The field held the go-libp2p error string, not the observed peer ID, which was misleading; the field is not consumed by any caller, so the rename is purely cosmetic. https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV
The manifest marks camera hardware as optional, and users may also deny the permission, so PairingActivity can no longer dead-end on those paths. Check PackageManager.FEATURE_CAMERA_ANY up front, route permission denials to the same path, and wrap ProcessCameraProvider.get() / bindToLifecycle() in try/catch so bind-time failures (front-only devices, emulators) also recover. The fallback opens an AlertDialog with a multiline paste field. The user copies the full pairing JSON (identity, PSK, network, node addresses) from the desktop app and pastes it there; the existing AuthNodeInfoValidator then feeds the same data into the pairing handshake as a QR scan would. https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 18 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees) | ||
| scanner.process(input) | ||
| .addOnSuccessListener { barcodes -> | ||
| val payload = barcodes.firstNotNullOfOrNull { it.rawValue } | ||
| if (!payload.isNullOrBlank() && fired.compareAndSet(false, true)) { |
There was a problem hiding this comment.
analyze() can start multiple concurrent scanner.process() Tasks because it returns immediately and there’s no “in-flight” guard. On a fast camera feed this can queue many ML Kit decodes at once (CPU/memory churn) even though only one result is needed. Consider adding an atomic “processing” flag (drop frames while a Task is running) and clearing it in addOnCompleteListener.
| analyzer = QrCodeAnalyzer { payload -> onQrScanned(payload) }.also { next -> | ||
| val provider = cameraProvider ?: return@runOnUiThread | ||
| provider.unbindAll() | ||
| val preview = Preview.Builder().build().also { | ||
| it.surfaceProvider = previewView.surfaceProvider | ||
| } | ||
| val imageAnalysis = ImageAnalysis.Builder() | ||
| .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) | ||
| .build() | ||
| imageAnalysis.setAnalyzer(cameraExecutor, next) | ||
| provider.bindToLifecycle( | ||
| this, | ||
| CameraSelector.DEFAULT_BACK_CAMERA, | ||
| preview, | ||
| imageAnalysis, | ||
| ) | ||
| } |
There was a problem hiding this comment.
The retry path re-binds CameraX without any exception handling. bindToLifecycle() can throw (e.g., no back camera / lifecycle state issues), which would crash on invalid-QR retries even though startCamera() handles these cases. Wrap this rebind in the same try/catch fallback used in startCamera() (or refactor to reuse startCamera()).
| analyzer = QrCodeAnalyzer { payload -> onQrScanned(payload) }.also { next -> | |
| val provider = cameraProvider ?: return@runOnUiThread | |
| provider.unbindAll() | |
| val preview = Preview.Builder().build().also { | |
| it.surfaceProvider = previewView.surfaceProvider | |
| } | |
| val imageAnalysis = ImageAnalysis.Builder() | |
| .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) | |
| .build() | |
| imageAnalysis.setAnalyzer(cameraExecutor, next) | |
| provider.bindToLifecycle( | |
| this, | |
| CameraSelector.DEFAULT_BACK_CAMERA, | |
| preview, | |
| imageAnalysis, | |
| ) | |
| } | |
| analyzer = QrCodeAnalyzer { payload -> onQrScanned(payload) } | |
| startCamera() |
| pskHex = paired.identity.psk, | ||
| bootstrapAddrs = bootstrap, | ||
| desktopPeerAddr = candidates.first(), | ||
| network = paired.network, | ||
| ) |
There was a problem hiding this comment.
desktopPeerAddr = candidates.first() assumes the first address is dialable. Validation only guarantees some address is parseable, so candidates.first() can still be an empty/invalid multiaddr and poison config (and any code paths that rely on desktopPeerAddr). Prefer selecting the first dialable candidate (or setting desktopPeerAddr to the address returned by connectAny()).
| android:background="#99000000" | ||
| android:textColor="#FFFFFF" |
There was a problem hiding this comment.
scanPrompt uses hard-coded colors (#99000000 / #FFFFFF), which ignores the Material3 theme and can cause contrast issues in different themes (e.g., black theme / dynamic color). Prefer using theme attributes (e.g., ?attr/colorSurfaceVariant + ?attr/colorOnSurfaceVariant) or a themed style so the overlay adapts correctly.
| android:background="#99000000" | |
| android:textColor="#FFFFFF" | |
| android:background="?attr/colorSurfaceVariant" | |
| android:textColor="?attr/colorOnSurfaceVariant" |
| <TextView | ||
| android:id="@+id/messageTitle" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:textAppearance="?attr/textAppearanceHeadline6" | ||
| android:paddingBottom="12dp" | ||
| tools:text="Connect to my node" /> | ||
|
|
||
| <TextView | ||
| android:id="@+id/messageBody" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:textAppearance="?attr/textAppearanceBody2" | ||
| android:paddingBottom="24dp" |
There was a problem hiding this comment.
Tusky uses a Material3 base theme (app/src/main/res/values/styles.xml sets TuskyBaseTheme → Theme.Material3.DayNight.NoActionBar), but this layout references Material2 typography attrs (?attr/textAppearanceHeadline6 / ?attr/textAppearanceBody2). These attrs may be unset under Material3, leading to inconsistent text styling. Consider switching to Material3 typography tokens (e.g., ?attr/textAppearanceTitleLarge, ?attr/textAppearanceBodyMedium) or explicit @style/TextAppearance.Material3.*.
| @Inject | ||
| lateinit var pairedNodeStore: com.keylesspalace.tusky.components.pairing.PairedNodeStore |
There was a problem hiding this comment.
Use an import for PairedNodeStore instead of the fully-qualified type in the field declaration to keep the file consistent with the rest of the imports and improve readability.
| kotlinx.coroutines.withTimeoutOrNull(DIAL_TIMEOUT_MILLIS) { | ||
| mutex.withLock { | ||
| if (_state.value == ConnectionState.Uninitialised) { | ||
| throw WarpnetException.NotInitialised() | ||
| } |
There was a problem hiding this comment.
connectAny() wraps binding.connect() in withTimeoutOrNull, but binding.connect() is a synchronous gomobile call (see node/mobile.go Connect → host.Connect with a 30s context). Coroutine timeouts won’t interrupt that blocking call, so the per-address 10s cap is ineffective and the UI can still hang ~30s per address. Consider moving the multi-address dial loop into the Go layer (accepting a per-address timeout) or exposing a cancellable/timeout-aware connect API from the binding.
| private val adapter = moshi.adapter<PairedNode>() | ||
| private val prefs: SharedPreferences = EncryptedSharedPreferences.create( | ||
| context, | ||
| PREFS_FILE, | ||
| MasterKey.Builder(context) | ||
| .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) | ||
| .build(), | ||
| EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | ||
| EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, | ||
| ) |
There was a problem hiding this comment.
EncryptedSharedPreferences.create() + MasterKey.Builder(...).build() run during DI construction and can do keystore I/O. Because PairedNodeStore is injected into MainActivity, this work is likely happening on the main thread during startup. Consider lazy-initializing the encrypted prefs (or injecting a Lazy<PairedNodeStore>/factory) so keystore setup doesn’t block UI startup.
| private val adapter = moshi.adapter<PairedNode>() | |
| private val prefs: SharedPreferences = EncryptedSharedPreferences.create( | |
| context, | |
| PREFS_FILE, | |
| MasterKey.Builder(context) | |
| .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) | |
| .build(), | |
| EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | |
| EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, | |
| ) | |
| private val appContext = context.applicationContext | |
| private val adapter = moshi.adapter<PairedNode>() | |
| private val prefs: SharedPreferences by lazy { | |
| EncryptedSharedPreferences.create( | |
| appContext, | |
| PREFS_FILE, | |
| MasterKey.Builder(appContext) | |
| .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) | |
| .build(), | |
| EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | |
| EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, | |
| ) | |
| } |
- PairingActivity invalid-QR retry: replace the inline rebind with a second startCamera() call so the retry path inherits the same try/catch fallback (prevents a crash on retry for devices that would fail bindToLifecycle on first start). - QrCodeAnalyzer: add an in-flight AtomicBoolean so only one ML Kit scanner.process() Task is queued at a time. Stops a fast camera feed from piling up parallel decodes before the first success. - activity_pairing.xml scanPrompt: swap the hardcoded #99000000 / white for ?attr/colorSurfaceVariant / colorOnSurfaceVariant so the overlay follows the Material3 theme. - activity_pairing.xml typography: switch textAppearanceHeadline6 / Body2 (Material2) to Material3 TitleLarge / BodyMedium to match the TuskyBaseTheme parent. - MainActivity: replace the fully-qualified PairedNodeStore with an import so the field matches the style of the surrounding DI fields. - PairingCoordinator: pick the first syntactically-dialable multiaddr for desktopPeerAddr rather than blindly candidates.first(); prevents an invalid entry earlier in the list from poisoning the config. Copilot's timeout and main-thread / lazy-prefs comments are left alone per the earlier "ignore timeout / ignore main-thread" direction. https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV
Summary
Implements a complete QR code-based pairing flow that allows users to scan a QR code from a Warpnet fat node and establish a secure connection. The flow includes camera preview, QR validation, confirmation dialog, and encrypted persistence of pairing credentials.
Key Changes
PairingActivity: New single-screen activity managing the full pairing lifecycle:
QR Code Scanning:
QrCodeAnalyzer: CameraX ImageAnalysis delegate using ML Kit barcode scannerPairing Validation & Persistence:
AuthNodeInfoValidator: Validates QR payload structure and required fieldsPairedNode: Data class holding identity, pinned peer ID, addresses, and network infoPairedNodeStore: Keystore-backed encrypted SharedPreferences for credential persistenceTransport Layer Integration:
PairingCoordinator: Orchestrates dial → pair → persist sequenceWarpnetClient.connectAny(): Tries candidate addresses with per-address 10s timeout; surfaces firewall hints on total failureWarpnetClient.pair(): Opens pairing stream, validates server response, distinguishes protocol errors from transport failuresWarpnetConfig: Now accepts configurablenetworkparameter (was hardcoded)MainActivity Integration:
UI & Resources:
activity_pairing.xml: Layout with camera preview, message panel, progress indicator, and button controlswarpnet_pair.xml: Localized strings for QR scanning, confirmation, and error messagesDependencies:
Notable Implementation Details
{"code":0,"message":"Accepted"}https://claude.ai/code/session_01YVkQT5maQWnBN4G59puLBV