diff --git a/.gitignore b/.gitignore index ce16ab2..ce8b49d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /sdkconfig /sdkconfig.old /managed_components/ +/apps/dist/ /android-companion/.gradle/ /android-companion/.kotlin/ /android-companion/**/build/ @@ -14,4 +15,3 @@ __pycache__/ *.py[cod] *.log .astra_* - diff --git a/ASTRA_TASKS.md b/ASTRA_TASKS.md index 88c91f2..aa9b8de 100644 --- a/ASTRA_TASKS.md +++ b/ASTRA_TASKS.md @@ -412,13 +412,17 @@ Each iteration picks the top unfinished task, implements production code, verifi - Added segmented Full/Minimal ZIP and application-image packaging with source/build manifests, license notices and download SHA256SUMS. Uploaded the assets to a `v1.2.0-preview.1` draft and verified a downloaded copy; production release publication remains R2.1. See [docs/RELEASE_PREPARATION.md](docs/RELEASE_PREPARATION.md). - Verified all 40 local Host targets, ShellCheck, package extraction/address/data-preservation/failure tests, native image descriptors, Full/Minimal builds and profile comparison. Fixed the GCC fixture include and firmware-container uv dependency exposed by remote CI; Host, Android and ESP32-S3 jobs pass. Draft images remain 2,495,312 / 521,648 bytes, with unchanged static RAM. -- [ ] **E2.3: 极客应用分发与打包工具链探索 (`.zapp` Package Specification & CLI Toolchain)** +- [x] **E2.3: 极客应用分发与打包工具链探索 (`.zapp` Package Specification & CLI Toolchain)** - **背景与愿景**: - 在 E2.2 动态沙箱原型落地后,为第三方开发者提供友好的外部构建与单文件打包规范(对标 Flipper Zero `.fap` 与 Android `.apk` 思想)。 - **交由 Astra 自由探索与权衡的开放性核心命题 (Open Architectural Questions for Astra to Explore)**: 1. *容器格式与元数据设计*:设计轻量自包含格式(如 `.zapp`),如何将 16x16 / 32x32 单色图标、应用名称、版本号、作者、权限声明、指令配额(Quota)与编译字节码打包为单一可交换二进制文件? 2. *极客命令行构建工具*:开发极简的打包与校验工具(使用 `bun` 或 `python`),支持开发者一键执行 `zapp build` 与 `zapp pack`; 3. *USB / Wi-Fi 传包协同*:与现有的 USB Manager 及 Web 传书通道无缝衔接,实现通过浏览器或命令行拖拽安装第三方应用。 + - Added portable version-1 `.zapp` containers with 16/32-pixel monochrome icons, UTF-8 names/authors, independent app/API versions, declared display/input permissions and enforced 100–10,000 instruction quotas. Packages carry source compiled by the bounded Lua VM at launch; external bytecode remains rejected. + - Shipped dependency-free `zapp build`, `pack` and `inspect` commands through uv, with Calculator/Flashcards manifests and original PBM icons. Added bounded metadata discovery and icon/version/author presentation to Apps without changing native scene or SDK ownership. See [docs/ZAPP_PACKAGES.md](docs/ZAPP_PACKAGES.md). + - Integrated streaming package validation into the existing storage transaction, USB app operations and authenticated Wi-Fi library. Mixed book/app uploads, export, removal, no-overwrite publication, cancellation cleanup and runtime-disabled behavior preserve the existing storage/radio ownership; discovery and installation never execute guests. + - Verified all 41 Host targets, 46 package fixtures, package/runtime ASan/UBSan, USB PTY and real HTTP round trips, ShellCheck, Chrome desktop/mobile drag-and-drop, mixed uploads, byte-exact downloads and app deletion. Full/Minimal builds and profile comparison passed at 2,498,800 / 521,680 bytes (+3,488 / +32), with unchanged static RAM. Hardware was not flashed in this iteration. - [ ] **C2.1: 官方伴侣端(Android Companion)深度端到端联调与 NFC 碰一碰实测 (Companion App & NDEF Qualification)** - **背景与愿景**: diff --git a/android-companion/README.md b/android-companion/README.md index de2fdd0..32d3400 100644 --- a/android-companion/README.md +++ b/android-companion/README.md @@ -14,7 +14,8 @@ It uses Android's built-in Kotlin support from Android Gradle Plugin 9.4. The UI uses Jetpack Compose and the public Material 3 Expressive API from Material 3 `1.5.0-alpha26`. The alpha dependency is isolated to the Android UI. -It does not define firmware or wire behavior. +It does not define firmware or wire behavior. The native C++ test fixtures also +require the host C/C++ compiler used by the firmware Host suite. The primary screen shows one connection state and one action. Technical state remains in the connection-details card. The application distinguishes these @@ -52,7 +53,49 @@ This builds a debug APK and runs the protocol, persistent queue and lifecycle state-machine tests. C++ remains the normative producer of `protocol/golden-vectors.json`; Kotlin must match its bytes. -## Physical gate +## Daily use + +Tap the Note4 NFC antenna to launch this app and select its exact BLE device. +Approve the Android association and confirm the Note4 passkey. Alternatively, +open local pairing on Note4 and use Pair a Note4. When several devices are +approved, select the intended address. Cancel connection stops an outstanding +attempt. Reconnect & sync time sends a fresh clock/timezone sample. + +Saved reading positions load without an active link. Resume queues a durable +update; Note4 offers it for local acceptance. Weather & time lets you find a +city and explicitly save its current Open-Meteo weather for the same device. +The daily sleep dashboard shows fresh weather; it is not a live sleep display. + +For books, open Wi-Fi Transfer on Note4, join its hotspot or LAN, and enter its +HTTP address and 12-character screen code under Books & sleep picture. Open +library supports bounded pages; Choose & send book accepts TXT/EPUB up to 4 MiB +and available storage. Choose sleep picture converts a photo to a monochrome +preview before Send picture. Finish releases device storage, then select +Sleep Cover > Phone Picture on Note4. Remove saved Note4 picture is explicit; +failed or cancelled uploads do not replace installed content. + +The local client accepts numeric private IPv4 addresses only, refuses redirects +and pins matching Wi-Fi routes for hotspots without Internet validation. Android +37 asks for local-network permission. The screen code and NFC proof are never +saved as preferences. Transfer work and prepared previews survive Activity +recreation; closing the Activity cancels its work without closing process-owned +BLE. System insets keep controls outside navigation bars and the keyboard. + +## Android framework integration test + +```bash +source tools/activate-dev-env.sh +ZECTRIX_ANDROID_SERIAL=emulator-5554 bash tools/test-android-device.sh +``` + +This installs the debug and instrumentation APKs and tests Android NDEF intent +routing, Keystore/file persistence, bitmap preparation and HTTP uploads to the +C++ server through ADB reverse. It needs an emulator or phone selected by ADB; +it does not emulate an NFC field or establish a physical BLE link. Current +results and the remaining phone procedure are in +[the C2.1 record](../docs/qualification/C2.1-COMPANION-INTEGRATION.md). + +## Physical qualification An APK build is not pairing evidence. C1.5 remains open until a real Android device validates Companion Device Manager association, GATT lifecycle, @@ -62,8 +105,9 @@ The app does not use periodic background scanning as its primary reconnect mechanism. It requests an association and registers a `CompanionDeviceService`. The process-owned GATT client connects only to an approved association. It serializes writes, supports fragmented frames and survives Activity teardown. -The `CompanionDeviceService` owns presence-based background connection. Failed -automatic attempts use bounded backoff; an explicit user action can retry at +The `CompanionDeviceService` owns presence-based background connection. Transient failed +automatic attempts use monotonic backoff and at most five retries while the +selected device is present; enrollment/protocol/recovery failures stop retries; an explicit user action can retry at once. Fresh pairing still requires a local Note4 action. The user must open the diff --git a/android-companion/app/build.gradle.kts b/android-companion/app/build.gradle.kts index a098823..1651390 100644 --- a/android-companion/app/build.gradle.kts +++ b/android-companion/app/build.gradle.kts @@ -28,5 +28,28 @@ android { dependencies { implementation("androidx.activity:activity-compose:1.13.0") implementation("androidx.compose.material3:material3:1.5.0-alpha26") + implementation("androidx.exifinterface:exifinterface:1.4.2") testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test:core:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") +} + +val companionFixtures = rootProject.layout.buildDirectory.dir("companion-fixtures") +val buildCompanionFixtures by tasks.registering(Exec::class) { + val repository = rootProject.projectDir.parentFile + inputs.files(fileTree("${repository}/components/zectrix_companion"), + fileTree("${repository}/components/zectrix_connectivity"), + fileTree("${repository}/components/zectrix_storage"), fileTree("${repository}/tools/host_include")) + inputs.files("${repository}/tools/build-companion-fixtures.sh", + "${repository}/tools/companion_peer_host.cc", "${repository}/tools/book_web_host.cc") + outputs.dir(companionFixtures) + commandLine("bash", "${repository}/tools/build-companion-fixtures.sh", companionFixtures.get().asFile.path) +} +tasks.withType().configureEach { + dependsOn(buildCompanionFixtures) + inputs.dir(companionFixtures) + systemProperty("zectrix.peer", companionFixtures.get().file("companion-peer-host").asFile.path) + systemProperty("zectrix.web", companionFixtures.get().file("book-web-host").asFile.path) } diff --git a/android-companion/app/src/androidTest/java/dev/zectrix/note4/companion/CompanionDeviceTest.kt b/android-companion/app/src/androidTest/java/dev/zectrix/note4/companion/CompanionDeviceTest.kt new file mode 100644 index 0000000..65e53b3 --- /dev/null +++ b/android-companion/app/src/androidTest/java/dev/zectrix/note4/companion/CompanionDeviceTest.kt @@ -0,0 +1,129 @@ +package dev.zectrix.note4.companion + +import android.content.Intent +import android.content.pm.PackageManager +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class CompanionDeviceTest { + private val context get() = ApplicationProvider.getApplicationContext() + private fun nfc(payload: ByteArray) = Intent(NfcAdapter.ACTION_NDEF_DISCOVERED).apply { + type = NfcEnrollmentParser.MIME_TYPE + setPackage(context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, arrayOf(NdefMessage(arrayOf(NdefRecord.createMime(NfcEnrollmentParser.MIME_TYPE, payload))))) + } + + @Test fun coldNdefLaunchResolvesAndDoesNotReplayAfterRecreation() { + val intent = nfc(ByteArray(50)) + val resolved = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + assertTrue(resolved.any { it.activityInfo.name == MainActivity::class.java.name }) + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { + assertFalse(it.intent.hasExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)) + assertNull(CompanionConnectionManager.pendingTapAddress()) + } + scenario.recreate() + scenario.onActivity { assertFalse(it.intent.hasExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)) } + } + } + + @Test fun warmNdefUsesExistingActivityAndRejectsAmbiguousRecords() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + var original: MainActivity? = null + scenario.onActivity { original = it } + context.startActivity(nfc(ByteArray(50)).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)) + instrumentation.waitForIdleSync() + scenario.onActivity { assertSame(original, it) } + val record = NdefRecord.createMime(NfcEnrollmentParser.MIME_TYPE, ByteArray(50)) + val ambiguous = nfc(ByteArray(50)).apply { + putExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, arrayOf(NdefMessage(arrayOf(record, record)))) + } + assertThrows(IllegalArgumentException::class.java) { NfcEnrollmentIntents.read(ambiguous) } + assertFalse(ambiguous.hasExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)) + assertNull(NfcEnrollmentIntents.read(Intent(Intent.ACTION_SEND).putExtras(nfc(ByteArray(50))))) + } + } + + @Test fun androidKeystoreAndAtomicQueueReallySurviveOwnerRecreation() { + val first = CompanionIdentityStore(context) + val identity = first.getOrCreate() + assertTrue(first.isDurable) + val reopened = CompanionIdentityStore(context) + assertArrayEquals(identity, reopened.getOrCreate()) + assertTrue(reopened.isDurable) + val file = File.createTempFile("qualification-", ".bin", context.noBackupFilesDir).apply { delete() } + try { + val queue = DurableQueue(file) + assertTrue(queue.load()) + val reading = ReaderProgress("Android.txt", 400, 0, 20, false, 150) + assertEquals(DurableResult.OK, queue.accept(DurableEntry(257, 4, ReaderProgressCodec.encode(reading)!!))) + assertTrue(ReaderProgressCodec.enqueue(queue, reading)) + val restored = DurableQueue(file) + assertTrue(restored.load()) + assertEquals(reading, ReaderProgressCodec.decode(restored.incoming(257)!!.payload)) + assertEquals(1, restored.snapshot().size) + } finally { file.delete() } + } + + @Test fun androidImageDecoderKeepsItsMonochromePreviewInTheViewModel() { + val file = File.createTempFile("qualification-", ".png", context.cacheDir) + val bitmap = android.graphics.Bitmap.createBitmap(800, 600, android.graphics.Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.BLACK) + file.outputStream().use { assertTrue(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)) } + bitmap.recycle() + val store = ViewModelStore() + try { + val model = ViewModelProvider(store, ViewModelProvider.AndroidViewModelFactory.getInstance(context))[CompanionTransferModel::class.java] + InstrumentationRegistry.getInstrumentation().runOnMainSync { model.preparePicture(android.net.Uri.fromFile(file)) } + val deadline = System.nanoTime() + 10_000_000_000L + while (model.busy && System.nanoTime() < deadline) Thread.sleep(25) + assertFalse(model.notice, model.busy) + val picture = requireNotNull(model.picture) { model.notice } + assertEquals(400, picture.width) + assertEquals(300, picture.height) + assertEquals(android.graphics.Color.BLACK, picture.getPixel(200, 150)) + val same = ViewModelProvider(store, ViewModelProvider.AndroidViewModelFactory.getInstance(context))[CompanionTransferModel::class.java] + assertSame(model, same) + } finally { + InstrumentationRegistry.getInstrumentation().runOnMainSync { store.clear() } + file.delete() + } + } + + @Test fun androidNetworkStackUploadsToTheCppFirmwareServer() { + val url = InstrumentationRegistry.getArguments().getString("transferUrl") + assumeTrue("Pass transferUrl from a running C++ book-web-host", url != null) + val endpoint = TransferEndpoint.parse(requireNotNull(url), allowLoopback = true) + val file = File.createTempFile("qualification-", ".txt", context.cacheDir) + try { + file.writeText("Android native network → C++ storage.\n".repeat(300)) + Note4TransferClient(endpoint, "ABCDEFGH2345").use { client -> + assertTrue(client.library().coverSupported) + val name = "Android-${System.currentTimeMillis()}.txt" + client.uploadBook(file, name) + assertTrue(client.library().books.any { it.name == name && it.size == file.length() }) + val cover = File.createTempFile("qualification-", ".pbm", context.cacheDir) + try { + cover.writeBytes(MonochromeCover.encode(IntArray(120000) { -1 })) + client.uploadCover(cover) + client.removeCover() + } finally { cover.delete() } + } + } finally { file.delete() } + } +} diff --git a/android-companion/app/src/main/AndroidManifest.xml b/android-companion/app/src/main/AndroidManifest.xml index 22bdb06..2dc8e9a 100644 --- a/android-companion/app/src/main/AndroidManifest.xml +++ b/android-companion/app/src/main/AndroidManifest.xml @@ -10,9 +10,13 @@ android:name="android.software.companion_device_setup" android:required="true" /> - + + + + + @@ -20,16 +24,23 @@ + + + + + = try { + val manager = context.getSystemService(CompanionDeviceManager::class.java) + val addresses = if (Build.VERSION.SDK_INT >= 33) { + manager.myAssociations.mapNotNull { it.deviceMacAddress?.toString() } + } else manager.associations + addresses.mapNotNull(::normalizeAddress).distinct() + } catch (_: Exception) { + emptyList() + } +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/BleGattClient.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/BleGattClient.kt index 90a8e26..6005ecb 100644 --- a/android-companion/app/src/main/java/dev/zectrix/note4/companion/BleGattClient.kt +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/BleGattClient.kt @@ -41,6 +41,7 @@ data class GattSnapshot( val detail: String, val receivedFrames: Int = 0, val sessionId: Int = 0, + val retryable: Boolean = false, ) @SuppressLint("MissingPermission") @@ -67,27 +68,35 @@ class BleGattClient( private val resourceGateway = PhoneResourceGateway() private var gatt: BluetoothGatt? = null private var tx: BluetoothGattCharacteristic? = null + private var latestSnapshot: GattSnapshot? = null private var state = GattState.IDLE private var mtu = 23 private var nextFrameId = 1 private var receivedFrames = 0 private var sessionId = 0 private var writeInFlight = false + private var inFlightPacket: ByteArray? = null + private var phaseDeadlineAt = 0L + private var writeDeadlineAt = 0L private var helloRequestId = 0L private var peerAuthorized = false - private var enrollmentProofPayload: ByteArray? = null - private var enrollmentProofExpiresAt = 0L + private val enrollment = EnrollmentHandoff() + private var selectedAddress: String? = null + private var notificationsEnabled = false + private var helloStarted = false private var durableQueue: DurableQueue? = null private var syncSession: CompanionSyncSession? = null private val handler = Handler(Looper.getMainLooper()) private val syncTick = Runnable { synchronized(this) { pollSync() } } - private val helloTimeout = Runnable { + private val phaseTimeout = Runnable { synchronized(this) { - if (state == GattState.VERIFYING_LINK || state == GattState.NEGOTIATING_PROTOCOL) { - failSession("Note4 handshake timed out; reconnect to retry") - } + if (phaseDeadlineAt != 0L && SystemClock.elapsedRealtime() >= phaseDeadlineAt) + failSession("Connection timed out during ${state.name.lowercase()}; retry when Note4 is nearby") } } + private val writeTimeout = Runnable { + synchronized(this) { if (writeInFlight && SystemClock.elapsedRealtime() >= writeDeadlineAt) failSession("Bluetooth write timed out; saved updates will retry") } + } private val pendingWrites = ArrayDeque() private val reassembler = CompanionFragments.Reassembler() private val bondReceiver = object : BroadcastReceiver() { @@ -111,8 +120,8 @@ class BleGattClient( BluetoothDevice.BOND_BONDING -> report(GattState.PAIRING, "Confirm the passkey shown on Note4") BluetoothDevice.BOND_BONDED -> startVerification(link) - BluetoothDevice.BOND_NONE -> if (state == GattState.PAIRING) { - report(GattState.FAULT, "Pairing failed. Open pairing on Note4 and try again") + BluetoothDevice.BOND_NONE -> if (state !in setOf(GattState.IDLE, GattState.DISCONNECTED, GattState.FAULT)) { + failSession("Pairing failed. Open pairing on Note4 and try again", retryable = false) } } } @@ -120,7 +129,7 @@ class BleGattClient( init { val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) if (Build.VERSION.SDK_INT >= 33) { - applicationContext.registerReceiver(bondReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + applicationContext.registerReceiver(bondReceiver, filter, Context.RECEIVER_EXPORTED) } else { applicationContext.registerReceiver(bondReceiver, filter) } @@ -136,52 +145,55 @@ class BleGattClient( event("connect_ignored", "reason=already_active") return false } + if (!prepareDevice(device.address)) return false + if (enrollment.address != null && enrollment.address != selectedAddress) { + failSession("Tap belongs to a different Note4", retryable = false) + return false + } + closeInternal(report = false) + syncSession = CompanionSyncSession(requireNotNull(durableQueue)) + sessionId = sessionSequence.updateAndGet { if (it == Int.MAX_VALUE) 1 else it + 1 } + report(GattState.CONNECTING, "Connecting to approved Note4") + gatt = try { + device.connectGatt(applicationContext, false, this, BluetoothDevice.TRANSPORT_LE) + } catch (_: Exception) { null } + if (gatt == null) { + failSession("Android did not start the GATT connection") + return false + } + return true + } + + /** Restore offline state before Bluetooth is available. Switching peers closes the old session. */ + @Synchronized + fun prepareDevice(address: String): Boolean { + val normalized = normalizeAddress(address) ?: return false + if (selectedAddress == normalized && durableQueue != null) return true closeInternal(report = false) durableQueue = null syncSession = null + selectedAddress = normalized val queue = DurableQueue(File(applicationContext.noBackupFilesDir, - "sync_${device.address.replace(":", "")}.bin")) + "sync_${normalized.replace(":", "")}.bin")) if (!queue.load()) { report(GattState.FAULT, "Saved sync state could not be read; recovery is required") return false } durableQueue = queue - syncSession = CompanionSyncSession(queue) - sessionId = sessionSequence.updateAndGet { if (it == Int.MAX_VALUE) 1 else it + 1 } - report(GattState.CONNECTING, "Connecting to approved Note4") - gatt = device.connectGatt(applicationContext, false, this, BluetoothDevice.TRANSPORT_LE) - if (gatt == null) { - report(GattState.FAULT, "Android did not start the GATT connection") - return false - } + report(GattState.IDLE, "Saved updates ready; connect to Note4") return true } @Synchronized fun close() { - enrollmentProofPayload?.fill(0) - enrollmentProofPayload = null + enrollment.clear() closeInternal(report = true) } - /** - * Supplies a single-use NFC enrollment proof for the next protocol Hello. - * The proof is discarded after its first send. If HelloAck is lost, the - * next connection presents the already persisted identity instead. - */ @Synchronized - fun setEnrollmentProof(generation: Long, token: ByteArray): Boolean { - if (!identityStore.isDurable || token.size != 16 || token.all { it == 0.toByte() } || - generation !in 1..0xffff_ffffL) return false - enrollmentProofPayload?.fill(0) - val value = CompanionProtocol.encodeHelloEnrollmentProof( - generation, token, companionIdentity, - ) - enrollmentProofPayload = CompanionProtocol.encodeTlv( - CompanionProtocol.HELLO_ENROLLMENT_PROOF_TYPE, required = true, value = value, - ) - value.fill(0) - enrollmentProofExpiresAt = SystemClock.elapsedRealtime() + 120_000 + fun setEnrollmentProof(record: CompanionEnrollmentRecord): Boolean { + if (!identityStore.isDurable || !enrollment.offer(record, SystemClock.elapsedRealtime())) return false + closeInternal(report = true) return true } @@ -215,25 +227,24 @@ class BleGattClient( } if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) { report(GattState.DISCOVERING, "Connected; checking Note4 service") - if (!gatt.discoverServices()) report(GattState.FAULT, "Service discovery did not start") + if (!gatt.discoverServices()) failSession("Service discovery did not start") return } if (newState == BluetoothProfile.STATE_DISCONNECTED) { val previousState = state - resetTransport() + closeInternal(report = false) report( GattState.DISCONNECTED, if (previousState == GattState.PAIRING) { "Pairing failed. Reopen pairing on Note4; if needed, forget its trusted phone first" } else { "Connection ended (code $status)" - }, + }, retryable = previousState != GattState.PAIRING, ) return } if (status != BluetoothGatt.GATT_SUCCESS) { - resetTransport() - report(GattState.FAULT, "Connection failed (code $status)") + failSession("Connection failed (code $status)") } } @@ -253,7 +264,7 @@ class BleGattClient( override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (gatt !== this.gatt) return if (status != BluetoothGatt.GATT_SUCCESS) { - report(GattState.FAULT, "Service discovery failed (code $status)") + failSession("Service discovery failed (code $status)") return } val service = gatt.getService(SERVICE_UUID) @@ -261,12 +272,12 @@ class BleGattClient( val rx = service?.getCharacteristic(NOTE_TO_PHONE_UUID) val cccd = rx?.getDescriptor(CCCD_UUID) if (tx == null || rx == null || cccd == null) { - report(GattState.FAULT, "This device does not provide the Note4 service") + failSession("This device does not provide the Note4 service", retryable = false) return } report(GattState.SUBSCRIBING, "Securing link and enabling updates") if (!gatt.setCharacteristicNotification(rx, true) || !writeDescriptor(gatt, cccd)) { - report(GattState.FAULT, "Notification setup did not start") + failSession("Notification setup did not start") } } @@ -274,9 +285,10 @@ class BleGattClient( override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { if (gatt !== this.gatt || descriptor.uuid != CCCD_UUID) return if (status != BluetoothGatt.GATT_SUCCESS) { - report(GattState.FAULT, "Secure notification setup failed (code $status)") + failSession("Secure notification setup failed (code $status)") return } + notificationsEnabled = true when (gatt.device.bondState) { BluetoothDevice.BOND_BONDED -> startVerification(gatt) BluetoothDevice.BOND_BONDING -> @@ -284,18 +296,16 @@ class BleGattClient( else -> { report(GattState.PAIRING, "Open pairing on Note4, then confirm its passkey") if (!gatt.device.createBond()) { - report(GattState.FAULT, "Android did not start secure pairing") + failSession("Android did not start secure pairing", retryable = false) } } } } private fun startVerification(gatt: BluetoothGatt) { - if (gatt !== this.gatt) return + if (gatt !== this.gatt || !notificationsEnabled || gatt.device.bondState != BluetoothDevice.BOND_BONDED) return if (state != GattState.SUBSCRIBING && state != GattState.PAIRING) return report(GattState.VERIFYING_LINK, "Verifying the encrypted Note4 link") - handler.removeCallbacks(helloTimeout) - handler.postDelayed(helloTimeout, 15_000) if (!gatt.requestMtu(REQUESTED_MTU)) { event("mtu_request_not_started", "value=$REQUESTED_MTU") sendHello(gatt) @@ -309,6 +319,10 @@ class BleGattClient( status: Int, ) { if (gatt !== this.gatt || characteristic.uuid != PHONE_TO_NOTE_UUID) return + handler.removeCallbacks(writeTimeout) + inFlightPacket?.fill(0) + inFlightPacket = null + writeDeadlineAt = 0 writeInFlight = false if (status != BluetoothGatt.GATT_SUCCESS) { pendingWrites.clear() @@ -377,6 +391,9 @@ class BleGattClient( val characteristic = tx ?: return val packet = pendingWrites.removeFirst() writeInFlight = true + inFlightPacket = packet + writeDeadlineAt = SystemClock.elapsedRealtime() + 10_000 + handler.postDelayed(writeTimeout, 10_000) if (!writeCharacteristic(gatt, characteristic, packet)) { writeInFlight = false pendingWrites.clear() @@ -385,24 +402,23 @@ class BleGattClient( } private fun sendHello(gatt: BluetoothGatt) { - if (state != GattState.VERIFYING_LINK || pendingWrites.isNotEmpty() || writeInFlight) return + if (state != GattState.VERIFYING_LINK || helloStarted || pendingWrites.isNotEmpty() || writeInFlight) return if (!identityStore.isDurable) { - failSession("Companion identity could not be saved securely") + failSession("Companion identity could not be saved securely", retryable = false) return } helloRequestId = sessionId.toLong() - if (enrollmentProofPayload != null && SystemClock.elapsedRealtime() >= enrollmentProofExpiresAt) { - enrollmentProofPayload?.fill(0) - enrollmentProofPayload = null - failSession("Enrollment expired; tap Note4 again") + val identityPayload = try { + enrollment.take(requireNotNull(selectedAddress), companionIdentity, SystemClock.elapsedRealtime()) + ?: CompanionProtocol.encodeTlv( + CompanionProtocol.HELLO_COMPANION_IDENTITY_TYPE, required = true, + value = CompanionProtocol.encodeCompanionIdentity(companionIdentity), + ) + } catch (failure: IllegalArgumentException) { + failSession(failure.message ?: "Tap Note4 again", retryable = false) return } - val identityPayload = enrollmentProofPayload ?: run { - CompanionProtocol.encodeTlv( - CompanionProtocol.HELLO_COMPANION_IDENTITY_TYPE, required = true, - value = CompanionProtocol.encodeCompanionIdentity(companionIdentity), - ) - } + helloStarted = true val now = System.currentTimeMillis() val payload = identityPayload + CompanionProtocol.encodeTlv(SyncWire.HELLO_CURSORS, true, SyncWire.encodeCursors(requireNotNull(durableQueue).cursors())) + @@ -418,8 +434,7 @@ class BleGattClient( payload, ) val fragments = CompanionFragments.encode(hello, nextFrameId++, mtu - 3) - enrollmentProofPayload?.fill(0) - enrollmentProofPayload = null + identityPayload.fill(0) payload.fill(0) hello.fill(0) fragments.forEach(pendingWrites::addLast) @@ -431,19 +446,27 @@ class BleGattClient( val previous = gatt gatt = null resetTransport() - previous?.disconnect() - previous?.close() + try { previous?.disconnect() } catch (_: Exception) { } + try { previous?.close() } catch (_: Exception) { } if (report) report(GattState.IDLE, "Not connected") else state = GattState.IDLE } private fun resetTransport() { handler.removeCallbacks(syncTick) - handler.removeCallbacks(helloTimeout) + handler.removeCallbacks(phaseTimeout) + handler.removeCallbacks(writeTimeout) + phaseDeadlineAt = 0 + writeDeadlineAt = 0 + inFlightPacket?.fill(0) + inFlightPacket = null syncSession?.disconnect() + notificationsEnabled = false + helloStarted = false tx = null mtu = 23 nextFrameId = 1 writeInFlight = false + pendingWrites.forEach { it.fill(0) } pendingWrites.clear() reassembler.reset() helloRequestId = 0 @@ -455,25 +478,25 @@ class BleGattClient( val (ack, cursors) = SyncWire.decodeHelloAck(frame.payload) if (ack.status != CompanionProtocol.HELLO_ACK_STATUS_OK || !ack.peerAuthorized) { identityStore.setEnrolled(false) - failSession("Enrollment rejected (code ${ack.errorReason})") + failSession("Enrollment rejected (code ${ack.errorReason})", retryable = false) return } requireNotNull(cursors) { "missing_sync_cursors" } val reconciled = requireNotNull(syncSession).start(cursors, SystemClock.elapsedRealtime()) if (reconciled != DurableResult.OK) { - failSession("Sync recovery required ($reconciled)") + failSession("Sync recovery required ($reconciled)", retryable = false) return } if (!identityStore.setEnrolled(true)) { - failSession("Enrollment could not be saved") + failSession("Enrollment could not be saved", retryable = false) return } peerAuthorized = true - handler.removeCallbacks(helloTimeout) + handler.removeCallbacks(phaseTimeout) report(GattState.SYNCHRONIZING, "Restoring saved updates") pollSync() } catch (_: IllegalArgumentException) { - failSession("Note4 returned an invalid handshake") + failSession("Note4 returned an invalid handshake", retryable = false) } } @@ -484,7 +507,8 @@ class BleGattClient( val now = SystemClock.elapsedRealtime() sync.poll(now, ::queueFrame) if (sync.status != SyncSessionStatus.ACTIVE) { - failSession("Sync interrupted (${sync.status}); saved updates will retry on reconnect") + failSession("Sync interrupted (${sync.status}); saved updates will retry on reconnect", + retryable = sync.status == SyncSessionStatus.TIMEOUT) return } if (sync.converged()) { @@ -502,6 +526,23 @@ class BleGattClient( return true } + @Synchronized + fun isCurrentSnapshot(snapshot: GattSnapshot): Boolean = latestSnapshot === snapshot + + @Synchronized + fun sendWeather(weather: WeatherSnapshot): Boolean { + val payload = runCatching { WeatherSync.encode(weather) }.getOrNull() ?: return false + if (durableQueue?.enqueue(WeatherSync.KEY, payload) != true) return false + pollSync() + return true + } + + @Synchronized + fun hasOfflineQueue(): Boolean = durableQueue != null + + @Synchronized + fun pendingUpdates(): Int = durableQueue?.snapshot()?.size ?: 0 + @Synchronized fun readDurableState(key: Int): DurableEntry? = durableQueue?.incoming(key) @@ -513,9 +554,9 @@ class BleGattClient( return true } - private fun failSession(detail: String) { + private fun failSession(detail: String, retryable: Boolean = true) { closeInternal(report = false) - report(GattState.FAULT, detail) + report(GattState.FAULT, detail, retryable) } private fun writeDescriptor(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor): Boolean = @@ -541,11 +582,24 @@ class BleGattClient( gatt.writeCharacteristic(characteristic) } - private fun report(newState: GattState, detail: String) { + private fun report(newState: GattState, detail: String, retryable: Boolean = false) { val previous = state + if (previous != newState) { + handler.removeCallbacks(phaseTimeout) + val timeout = when (newState) { + GattState.CONNECTING, GattState.SUBSCRIBING -> 30_000L + GattState.PAIRING -> 90_000L + GattState.DISCOVERING, GattState.VERIFYING_LINK, GattState.NEGOTIATING_PROTOCOL -> 15_000L + else -> 0L + } + phaseDeadlineAt = if (timeout == 0L) 0 else SystemClock.elapsedRealtime() + timeout + if (timeout > 0) handler.postDelayed(phaseTimeout, timeout) + } state = newState event("gatt_state", "from=$previous to=$newState detail=${detail.replace(' ', '_')}") - listener(GattSnapshot(newState, detail, receivedFrames, sessionId)) + val snapshot = GattSnapshot(newState, detail, receivedFrames, sessionId, retryable) + latestSnapshot = snapshot + listener(snapshot) } private fun event(name: String, fields: String) { diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionConnectionManager.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionConnectionManager.kt index 7aaf237..5ed0760 100644 --- a/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionConnectionManager.kt +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionConnectionManager.kt @@ -3,76 +3,170 @@ package dev.zectrix.note4.companion import android.annotation.SuppressLint import android.bluetooth.BluetoothManager import android.content.Context -import android.util.Log +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import java.util.concurrent.CopyOnWriteArraySet -/** Process-owned BLE transport. Activities observe it but do not own its lifetime. */ +/** Process-owned transport and offline queue. An Activity only observes this owner. */ object CompanionConnectionManager { private val listeners = CopyOnWriteArraySet<(GattSnapshot) -> Unit>() + private val handler = Handler(Looper.getMainLooper()) private var client: BleGattClient? = null + private var application: Context? = null @Volatile private var current = GattSnapshot(GattState.IDLE, "Not connected") - @Volatile private var nextAutomaticAttemptAt = 0L + private var selectedAddress: String? = null + private var tappedAddress: String? = null + private val present = mutableSetOf() private var automaticFailures = 0 + private var nextAutomaticAttemptAt = 0L + private var reconnect = true + private val retry = Runnable { + val context = application + val address = selectedAddress + if (context != null && address != null && address in present && reconnect) { + connectApproved(context, address, automatic = true) + } + } + @Synchronized fun initialize(context: Context) { if (client != null) return - synchronized(this) { - if (client == null) { - client = BleGattClient(context.applicationContext) { snapshot -> - current = snapshot - if (snapshot.state == GattState.READY) { - automaticFailures = 0 - nextAutomaticAttemptAt = 0 - } else if (snapshot.state == GattState.FAULT || - snapshot.state == GattState.DISCONNECTED) { - automaticFailures = (automaticFailures + 1).coerceAtMost(5) - nextAutomaticAttemptAt = System.currentTimeMillis() + - (15_000L shl automaticFailures) + application = context.applicationContext + client = BleGattClient(context.applicationContext) { snapshot -> + // GATT callbacks never acquire manager state while holding the client's lock. + handler.post { + if (client?.isCurrentSnapshot(snapshot) != true) return@post + publish(snapshot) + if (snapshot.state == GattState.READY) { + tappedAddress = null + automaticFailures = 0 + nextAutomaticAttemptAt = 0 + handler.removeCallbacks(retry) + } else if (snapshot.state == GattState.FAULT || snapshot.state == GattState.DISCONNECTED) { + if (snapshot.retryable) { + automaticFailures++ + nextAutomaticAttemptAt = SystemClock.elapsedRealtime() + (15_000L shl (automaticFailures - 1).coerceIn(0, 4)) + scheduleRetry() + } else { + reconnect = false + handler.removeCallbacks(retry) } - listeners.forEach { it(snapshot) } } } } + val preferences = context.getSharedPreferences("companion", Context.MODE_PRIVATE) + selectedAddress = selectApprovedPeer(ApprovedDevices.addresses(context), null, preferences.getString("selected_address", null)) + selectedAddress?.let { client?.prepareDevice(it) } + } + + private fun publish(snapshot: GattSnapshot) { + current = snapshot + listeners.forEach { it(snapshot) } } fun snapshot(): GattSnapshot = current + fun preferredAddress(): String? = tappedAddress ?: selectedAddress + fun pendingTapAddress(): String? = tappedAddress + fun hasOfflineQueue(): Boolean = selectedAddress != null && (tappedAddress == null || tappedAddress == selectedAddress) && client?.hasOfflineQueue() == true + fun pendingUpdates(): Int = if (hasOfflineQueue()) client?.pendingUpdates() ?: 0 else 0 fun observe(listener: (GattSnapshot) -> Unit) { listeners += listener listener(current) } - fun removeObserver(listener: (GattSnapshot) -> Unit) { - listeners -= listener + fun removeObserver(listener: (GattSnapshot) -> Unit) { listeners -= listener } + + fun selectApproved(context: Context, address: String): Boolean { + initialize(context) + val target = normalizeAddress(address)?.takeIf { it in ApprovedDevices.addresses(context) } ?: return false + if (tappedAddress != null && tappedAddress != target) return false + val preferences = context.getSharedPreferences("companion", Context.MODE_PRIVATE) + if (preferences.getString("selected_address", null) != target && + !preferences.edit().putString("selected_address", target).commit()) { + publish(GattSnapshot(GattState.FAULT, "Could not save the selected Note4")) + return false + } + if (!client!!.prepareDevice(target)) return false + selectedAddress = target + return true } @SuppressLint("MissingPermission") fun connectApproved(context: Context, address: String, automatic: Boolean = false): Boolean { initialize(context) - if (automatic && System.currentTimeMillis() < nextAutomaticAttemptAt) { - Log.i("ZectrixCompanion", "event=automatic_connect_deferred reason=backoff") + val target = normalizeAddress(address) ?: return false + if (automatic && target != selectedAddress) return false + if (automatic && SystemClock.elapsedRealtime() < nextAutomaticAttemptAt) { + scheduleRetry() return false } + if (!selectApproved(context, target)) return false + reconnect = true if (!automatic) { + handler.removeCallbacks(retry) automaticFailures = 0 nextAutomaticAttemptAt = 0 } - val device = context.getSystemService(BluetoothManager::class.java) - .adapter.getRemoteDevice(address) - return client?.connect(device) == true + return try { + val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter + if (adapter == null || !adapter.isEnabled) { + publish(GattSnapshot(GattState.FAULT, "Turn on Bluetooth, then connect again")) + false + } else client?.connect(adapter.getRemoteDevice(target)) == true + } catch (_: SecurityException) { + publish(GattSnapshot(GattState.FAULT, "Allow Nearby devices to connect to Note4")) + false + } catch (_: IllegalArgumentException) { + publish(GattSnapshot(GattState.FAULT, "The approved device address is invalid")) + false + } } - fun setEnrollmentProof(generation: Long, token: ByteArray): Boolean { - return client?.setEnrollmentProof(generation, token) ?: false + fun setEnrollmentProof(record: CompanionEnrollmentRecord): Boolean { + if (client?.setEnrollmentProof(record) != true) return false + tappedAddress = record.targetAddress() + handler.removeCallbacks(retry) + automaticFailures = 0 + return true + } + + fun deviceAppeared(context: Context, address: String) { + initialize(context) + val target = normalizeAddress(address) ?: return + if (target !in ApprovedDevices.addresses(context)) return + present += target + if (target == selectedAddress && reconnect) connectApproved(context, target, automatic = true) + } + + fun deviceDisappeared(address: String) { + val target = normalizeAddress(address) ?: return + present -= target + if (target == selectedAddress) { + handler.removeCallbacks(retry) + client?.close() + } + } + + private fun scheduleRetry() { + handler.removeCallbacks(retry) + if (reconnect && selectedAddress in present && automaticFailures <= 5) { + handler.postDelayed(retry, (nextAutomaticAttemptAt - SystemClock.elapsedRealtime()).coerceAtLeast(1000)) + } } fun stop() { + reconnect = false + tappedAddress = null + handler.removeCallbacks(retry) client?.close() } fun putDurableState(entry: DurableEntry): Boolean = client?.putDurableState(entry) == true fun readDurableState(key: Int): DurableEntry? = client?.readDurableState(key) - fun readingProgress(): ReaderProgress? = readDurableState(ReaderProgressCodec.SYNC_KEY) + fun readingProgress(): ReaderProgress? = if (!hasOfflineQueue()) null else readDurableState(ReaderProgressCodec.SYNC_KEY) ?.let { ReaderProgressCodec.decode(it.payload) } + fun sendWeather(weather: WeatherSnapshot): Boolean = client?.sendWeather(weather) == true fun sendReadingProgress(progress: ReaderProgress): Boolean = client?.sendReadingProgress(progress) == true } diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionExtras.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionExtras.kt new file mode 100644 index 0000000..b13ac4e --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionExtras.kt @@ -0,0 +1,138 @@ +package dev.zectrix.note4.companion + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.ui.platform.LocalContext +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun CompanionExtras(model: CompanionTransferModel, canSync: Boolean, pendingUpdates: Int) { + val context = LocalContext.current + var localNetworkAllowed by remember { + mutableStateOf(Build.VERSION.SDK_INT < 37 || context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED) + } + val networkPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { localNetworkAllowed = it } + val canTransfer = localNetworkAllowed && !model.busy + val bookPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) model.uploadBook(uri) + } + val picturePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) model.preparePicture(uri) + } + var confirmRemove by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + if (model.notice.isNotEmpty() || model.busy) ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(model.notice) + if (model.busy) { + LinearProgressIndicator(progress = { model.progress }, modifier = Modifier.fillMaxWidth()) + TextButton(onClick = model::cancel) { Text("Cancel") } + } + } + } + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Books & sleep picture", style = MaterialTheme.typography.titleMedium) + Text("Open Wi-Fi Transfer on Note4. Join its hotspot or the same Wi-Fi, then enter the address and access code on its screen.", + style = MaterialTheme.typography.bodyMedium) + if (!localNetworkAllowed) { + Button(onClick = { networkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) }) { Text("Allow local network") } + } + TextButton(onClick = { context.startActivity(android.content.Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)) }) { + Text("Open Wi-Fi settings") + } + OutlinedTextField(model.address, { model.address = it }, enabled = !model.busy, + singleLine = true, label = { Text("Note4 address") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(model.code, { model.code = it }, enabled = !model.busy, + singleLine = true, label = { Text("Access code") }, visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth()) + FilledTonalButton(onClick = { model.refresh() }, enabled = canTransfer, modifier = Modifier.fillMaxWidth()) { + Text("Open library") + } + model.library?.let { library -> + Text("${library.available / 1024} KiB available", style = MaterialTheme.typography.bodySmall) + if (library.books.isEmpty()) Text("Your library is empty") + library.books.forEach { Text("${it.name} · ${it.size / 1024} KiB", style = MaterialTheme.typography.bodyMedium) } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton(onClick = { model.refresh() }, enabled = canTransfer) { Text("First page") } + TextButton(onClick = { model.refresh(true) }, enabled = canTransfer && library.more) { Text("Next page") } + } + } + Button(onClick = { bookPicker.launch(arrayOf("text/plain", "application/epub+zip", "application/octet-stream")) }, + enabled = canTransfer, modifier = Modifier.fillMaxWidth()) { Text("Choose & send book") } + Text("TXT / EPUB, up to 4 MiB. Existing filenames are kept.", style = MaterialTheme.typography.bodySmall) + if (model.library?.coverSupported != false) { + FilledTonalButton(onClick = { picturePicker.launch(arrayOf("image/*")) }, enabled = !model.busy, + modifier = Modifier.fillMaxWidth()) { Text("Choose sleep picture") } + model.picture?.let { picture -> + Image(picture.asImageBitmap(), "Monochrome sleep picture preview", + modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f)) + Button(onClick = model::uploadPicture, enabled = canTransfer, modifier = Modifier.fillMaxWidth()) { Text("Send picture") } + } + TextButton(onClick = { confirmRemove = true }, enabled = canTransfer) { Text("Remove saved Note4 picture") } + } + FilledTonalButton(onClick = model::finish, enabled = canTransfer, modifier = Modifier.fillMaxWidth()) { Text("Finish transfer") } + } + } + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Weather & time", style = MaterialTheme.typography.titleMedium) + Text("Phone time and timezone are sent on each secure connection. Reading positions and weather stay queued while Note4 is offline.", + style = MaterialTheme.typography.bodyMedium) + Text("Saved updates waiting: $pendingUpdates", style = MaterialTheme.typography.bodySmall) + OutlinedTextField(model.city, { model.city = it }, enabled = !model.busy, singleLine = true, + label = { Text("City") }, modifier = Modifier.fillMaxWidth()) + FilledTonalButton(onClick = model::searchWeather, enabled = canSync && !model.busy && model.city.isNotBlank()) { Text("Find city") } + if (!canSync) Text("Pair and select a Note4 to save weather updates.", style = MaterialTheme.typography.bodySmall) + model.places.forEach { place -> + TextButton(onClick = { model.syncWeather(place) }, enabled = !model.busy && canSync) { + Text("${place.name} · ${place.region}") + } + } + model.weather?.let { weather -> + Text("${weather.place} · ${weather.deciCelsius / 10.0} °C · ${WeatherSync.describe(weather.code)}") + Text("Observation: ${java.time.Instant.ofEpochSecond(weather.observedAt).atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()}", + style = MaterialTheme.typography.bodySmall) + } + Text("Weather data: Open-Meteo (CC BY 4.0). Updates are requested only when you choose a city.", + style = MaterialTheme.typography.bodySmall) + } + } + } + if (confirmRemove) AlertDialog( + onDismissRequest = { confirmRemove = false }, + title = { Text("Remove Note4 picture?") }, + text = { Text("This removes the saved sleep picture from Note4. Books and phone photos are kept.") }, + confirmButton = { TextButton(onClick = { confirmRemove = false; model.removePicture() }) { Text("Remove") } }, + dismissButton = { TextButton(onClick = { confirmRemove = false }) { Text("Cancel") } }, + ) +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionTransferModel.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionTransferModel.kt new file mode 100644 index 0000000..8385d21 --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/CompanionTransferModel.kt @@ -0,0 +1,258 @@ +package dev.zectrix.note4.companion + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.OpenableColumns +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.AndroidViewModel +import java.io.File +import java.io.InputStream +import java.util.concurrent.Executors +import java.util.concurrent.Future + +class CompanionTransferModel(application: Application) : AndroidViewModel(application) { + private val preferences = application.getSharedPreferences("companion", Application.MODE_PRIVATE) + var address by mutableStateOf(preferences.getString("transfer_address", "http://192.168.4.1")!!) + var code by mutableStateOf("") + var city by mutableStateOf(preferences.getString("weather_city", "")!!) + var busy by mutableStateOf(false) + private set + var notice by mutableStateOf("") + private set + var progress by mutableStateOf(0f) + private set + var library by mutableStateOf(null) + private set + var picture by mutableStateOf(null) + private set + var places by mutableStateOf>(emptyList()) + private set + var weather by mutableStateOf(null) + private set + private var preparedPicture: File? = null + private var libraryOrigin: String? = null + private val executor = Executors.newSingleThreadExecutor() + private val handler = Handler(Looper.getMainLooper()) + private var task: Future<*>? = null + private var generation = 0 + @Volatile private var active: AutoCloseable? = null + @Volatile private var source: InputStream? = null + + private fun run(message: String, operation: (Int) -> Unit) { + if (busy) return + val ticket = ++generation + busy = true + progress = 0f + notice = message + task = executor.submit { + try { operation(ticket) } + catch (failure: Exception) { + update(ticket) { notice = failure.message?.take(240) ?: "Operation failed; check Note4 before retrying" } + } finally { + runCatching { active?.close() } + active = null + update(ticket) { busy = false } + } + } + } + + private fun update(ticket: Int, change: () -> Unit) { + handler.post { if (ticket == generation) change() } + } + + private fun client(): Note4TransferClient { + val endpoint = TransferEndpoint.parse(address) + preferences.edit().putString("transfer_address", endpoint.origin).apply() + return Note4TransferClient(endpoint, code) { openLocalTransfer(getApplication(), it) }.also { active = it } + } + + fun refresh(next: Boolean = false) { + val after = if (next) library?.books?.lastOrNull()?.name else null + val origin = address + if (next && libraryOrigin != origin) return + run("Reading Note4 library…") { ticket -> + val listing = client().library(after) + update(ticket) { library = listing; libraryOrigin = origin; notice = "${listing.books.size} books on this page" } + } + } + + fun uploadBook(uri: Uri) = run("Preparing book…") { ticket -> + val resolver = getApplication().contentResolver + val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } ?: throw IllegalArgumentException("The selected document has no filename") + require(Note4TransferClient.validBookName(name)) { "Choose a TXT or EPUB with a filename of at most 63 UTF-8 bytes" } + val file = stage(uri, Note4TransferClient.BOOK_LIMIT) + try { + val client = client() + client.uploadBook(file, name, reportProgress(ticket)) + val listing = runCatching { client.library() }.getOrNull() + update(ticket) { + if (listing != null) { library = listing; libraryOrigin = address } + notice = "$name saved. Finish the transfer to open it in Note4 Reader." + } + } finally { file.delete() } + } + + fun preparePicture(uri: Uri) = run("Preparing monochrome picture…") { ticket -> + val sourceFile = stage(uri, 24L * 1024 * 1024) + var pbm: File? = null + try { + val bitmap = renderPicture(sourceFile) + val pixels = IntArray(MonochromeCover.WIDTH * MonochromeCover.HEIGHT) + bitmap.getPixels(pixels, 0, MonochromeCover.WIDTH, 0, 0, MonochromeCover.WIDTH, MonochromeCover.HEIGHT) + val bytes = MonochromeCover.encode(pixels) + for (i in pixels.indices) pixels[i] = if (bytes[MonochromeCover.HEADER.size + i / 8].toInt() and (0x80 ushr (i % 8)) != 0) Color.BLACK else Color.WHITE + bitmap.setPixels(pixels, 0, MonochromeCover.WIDTH, 0, 0, MonochromeCover.WIDTH, MonochromeCover.HEIGHT) + pbm = File.createTempFile("note4-cover-", ".pbm", getApplication().cacheDir).also { it.writeBytes(bytes) } + val ready = pbm + handler.post { + if (ticket == generation) { + preparedPicture?.delete() + preparedPicture = ready + picture = bitmap + notice = "Picture ready. Note4 retains the image and overlays its wake hint." + } else { ready?.delete(); bitmap.recycle() } + } + pbm = null + } finally { sourceFile.delete(); pbm?.delete() } + } + + fun uploadPicture() { + val file = preparedPicture ?: return + run("Sending picture…") { ticket -> + client().uploadCover(file, reportProgress(ticket)) + update(ticket) { notice = "Picture saved. Finish transfer, then choose Sleep Cover → Phone Picture on Note4." } + } + } + + fun removePicture() = run("Removing saved Note4 picture…") { ticket -> + client().removeCover() + update(ticket) { notice = "Saved Note4 picture removed. You can now send another picture." } + } + + fun finish() = run("Finishing transfer…") { ticket -> + client().finish() + update(ticket) { library = null; code = ""; notice = "Transfer finished. Note4 can use its library again." } + } + + fun searchWeather() = run("Finding cities…") { ticket -> + val client = PhoneWeatherClient().also { active = it } + val found = client.search(city) + update(ticket) { places = found; notice = if (found.isEmpty()) "No city found" else "Choose your city" } + } + + fun syncWeather(place: WeatherPlace) { + val peer = CompanionConnectionManager.preferredAddress() ?: return + run("Reading weather…") { ticket -> + val client = PhoneWeatherClient().also { active = it } + val sample = client.current(place, System.currentTimeMillis() / 1000) + update(ticket) { + if (CompanionConnectionManager.preferredAddress() != peer || !CompanionConnectionManager.sendWeather(sample)) { + notice = "Weather could not be queued for the selected Note4; try again" + } else { + weather = sample + places = emptyList() + preferences.edit().putString("weather_city", city).apply() + notice = "Weather saved for sync. Note4 shows fresh weather on its daily sleep dashboard." + } + } + } + } + + private fun reportProgress(ticket: Int): (Long, Long) -> Unit { + var lastPercent = -1L + return { sent, total -> + val percent = if (total == 0L) 100 else sent * 100 / total + if (percent != lastPercent) { + lastPercent = percent + update(ticket) { progress = percent / 100f } + } + } + } + + private fun stage(uri: Uri, limit: Long): File { + val file = File.createTempFile("note4-send-", ".part", getApplication().cacheDir) + try { + val input = getApplication().contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("Cannot open the selected document") + source = input + input.use { + file.outputStream().use { output -> + val buffer = ByteArray(8192) + var count = 0L + while (true) { + check(!Thread.currentThread().isInterrupted) { "Cancelled" } + val read = input.read(buffer) + if (read < 0) break + count += read + require(count <= limit) { "Selected file is too large" } + output.write(buffer, 0, read) + } + } + } + return file + } catch (failure: Exception) { file.delete(); throw failure } + finally { source = null } + } + + private fun renderPicture(file: File): Bitmap { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.path, bounds) + require(bounds.outWidth in 1..65535 && bounds.outHeight in 1..65535) { "Choose a supported image" } + var sample = 1 + while ((bounds.outWidth.toLong() / sample + 1) * (bounds.outHeight.toLong() / sample + 1) > 2_000_000) sample *= 2 + val decoded = BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { + inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 + }) ?: throw IllegalArgumentException("Could not decode the selected picture") + val result = Bitmap.createBitmap(MonochromeCover.WIDTH, MonochromeCover.HEIGHT, Bitmap.Config.ARGB_8888) + try { + val exif = ExifInterface(file) + val transform = Matrix().apply { + if (exif.isFlipped) postScale(-1f, 1f) + postRotate(exif.rotationDegrees.toFloat()) + } + val oriented = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, transform, true) + try { + val scale = minOf(result.width.toFloat() / oriented.width, result.height.toFloat() / oriented.height) + val width = oriented.width * scale + val height = oriented.height * scale + val left = (result.width - width) / 2 + val top = (result.height - height) / 2 + Canvas(result).apply { + drawColor(Color.WHITE) + drawBitmap(oriented, null, RectF(left, top, left + width, top + height), Paint(Paint.FILTER_BITMAP_FLAG)) + } + } finally { if (oriented !== decoded) oriented.recycle() } + return result + } catch (failure: Exception) { result.recycle(); throw failure } + finally { decoded.recycle() } + } + + fun cancel() { + generation++ + task?.cancel(true) + runCatching { active?.close() } + runCatching { source?.close() } + busy = false + notice = "Cancelled. If sending had started, check the Note4 library before retrying." + } + + override fun onCleared() { + cancel() + executor.shutdownNow() + preparedPicture?.delete() + } +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/DurableQueue.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/DurableQueue.kt index ab9d55b..d2a99fa 100644 --- a/android-companion/app/src/main/java/dev/zectrix/note4/companion/DurableQueue.kt +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/DurableQueue.kt @@ -135,6 +135,13 @@ class DurableQueue(private val storage: DurableStorage) { return change { put(entry.key, (old ?: State(entry.key)).copy(pending = entry.copied())) } } + fun enqueue(key: Int, payload: ByteArray): Boolean { + val state = entries[key] + if (state?.pending?.payload?.contentEquals(payload) == true) return true + val revision = maxOf(state?.acknowledged ?: 0L, state?.pending?.revision ?: 0L) + 1 + return put(DurableEntry(key, revision, payload)) + } + fun acknowledge(key: Int, revision: Long): Boolean { if (!loaded || revision !in 1..0xffff_ffffL) return false val old = entries[key] ?: return false diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/EnrollmentHandoff.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/EnrollmentHandoff.kt new file mode 100644 index 0000000..dbce96a --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/EnrollmentHandoff.kt @@ -0,0 +1,64 @@ +package dev.zectrix.note4.companion + +import java.util.Locale + +/** A tap authorizes one Hello to one peripheral, within a monotonic deadline. */ +class EnrollmentHandoff { + companion object { const val LIFETIME_MS = 120_000L } + + var address: String? = null + private set + private var record: CompanionEnrollmentRecord? = null + private var expiresAt = 0L + + fun offer(value: CompanionEnrollmentRecord, now: Long): Boolean { + val target = value.targetAddress() ?: return false + if (value.deviceId.size != 16 || value.deviceId.all { it == 0.toByte() } || + value.token.size != 16 || value.token.all { it == 0.toByte() } || + value.generation !in 1..0xffff_ffffL || now < 0 || now > Long.MAX_VALUE - LIFETIME_MS) return false + clear() + record = value.copy(bleAddress = value.bleAddress.copyOf(), deviceId = value.deviceId.copyOf(), + token = value.token.copyOf()) + address = target + expiresAt = now + LIFETIME_MS + return true + } + + fun take(target: String, identity: ByteArray, now: Long): ByteArray? { + val pending = record ?: return null + try { + require(normalizeAddress(target) == address) { "Tap belongs to a different Note4" } + require(now >= expiresAt - LIFETIME_MS && now < expiresAt) { "Enrollment expired; tap Note4 again" } + val proof = CompanionProtocol.encodeHelloEnrollmentProof(pending.generation, pending.token, identity) + return try { CompanionProtocol.encodeTlv(CompanionProtocol.HELLO_ENROLLMENT_PROOF_TYPE, true, proof) } + finally { proof.fill(0) } + } finally { + // Failed or interrupted sends must never replay a one-use token. + clear() + } + } + + fun clear() { + record?.token?.fill(0) + record = null + address = null + expiresAt = 0 + } +} + +fun normalizeAddress(value: String?): String? = value?.takeIf { + it.matches(Regex("[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}")) && + it != "00:00:00:00:00:00" && !it.equals("FF:FF:FF:FF:FF:FF", true) +}?.uppercase(Locale.ROOT) + +fun CompanionEnrollmentRecord.targetAddress(): String? { + if (version != 1 || flags != 1 || bleRole != 1 || bleAddress.size != 6 || + bleAddressType !in 0..1 || (bleAddressType == 1 && bleAddress[0].toInt() and 0xc0 != 0xc0)) return null + return normalizeAddress(bleAddress.joinToString(":") { "%02X".format(it.toInt() and 0xff) }) +} + +fun selectApprovedPeer(approved: List, tapped: String?, preferred: String?): String? { + val addresses = approved.mapNotNull(::normalizeAddress).distinct() + if (tapped != null) return normalizeAddress(tapped)?.takeIf { it in addresses } + return normalizeAddress(preferred)?.takeIf { it in addresses } ?: addresses.singleOrNull() +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/LocalTransferNetwork.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/LocalTransferNetwork.kt new file mode 100644 index 0000000..90bd40e --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/LocalTransferNetwork.kt @@ -0,0 +1,27 @@ +package dev.zectrix.note4.companion + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.URL + +/** A Note4 hotspot has no Internet validation and may not be Android's default network. */ +@Suppress("DEPRECATION") +fun openLocalTransfer(context: Context, url: URL): HttpURLConnection { + val connectivity = context.getSystemService(ConnectivityManager::class.java) + val destination = InetAddress.getByName(url.host) + val candidates = connectivity.allNetworks.mapNotNull { network -> + val capabilities = connectivity.getNetworkCapabilities(network) ?: return@mapNotNull null + if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return@mapNotNull null + val prefix = connectivity.getLinkProperties(network)?.routes + ?.filter { it.destination.prefixLength > 0 && it.matches(destination) } + ?.maxOfOrNull { it.destination.prefixLength } ?: return@mapNotNull null + network to prefix + } + val longest = candidates.maxOfOrNull { it.second } + val matching = candidates.filter { it.second == longest } + require(matching.size <= 1) { "More than one Wi-Fi route matches Note4; choose its network again" } + return (matching.singleOrNull()?.first?.openConnection(url) ?: url.openConnection()) as HttpURLConnection +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/MainActivity.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/MainActivity.kt index b77fdf3..c3e5ccf 100644 --- a/android-companion/app/src/main/java/dev/zectrix/note4/companion/MainActivity.kt +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/MainActivity.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Activity import android.app.PendingIntent import android.companion.AssociationRequest +import android.bluetooth.le.ScanFilter import android.companion.BluetoothLeDeviceFilter import android.companion.CompanionDeviceManager import android.content.pm.PackageManager @@ -11,18 +12,21 @@ import android.os.Build import android.os.Bundle import android.content.Intent import android.content.IntentFilter -import android.nfc.NdefMessage -import android.nfc.NdefRecord import android.nfc.NfcAdapter import android.nfc.NfcManager import android.os.Handler import android.os.Looper import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -54,19 +58,26 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import java.util.regex.Pattern +import androidx.lifecycle.ViewModelProvider class MainActivity : ComponentActivity() { companion object { private const val ASSOCIATION_RESULT_OK = Activity.RESULT_OK } + private lateinit var transfers: CompanionTransferModel + private var approvedDevices by mutableStateOf>(emptyList()) + private var associationPending by mutableStateOf(false) + private var pendingUpdates by mutableIntStateOf(0) private var snapshot by mutableStateOf(CompanionConnectionManager.snapshot()) private var readingProgress by mutableStateOf(null) + private var actionNotice by mutableStateOf(null) private var readerNotice by mutableStateOf(null) private val readerHandler = Handler(Looper.getMainLooper()) private val readerTick = object : Runnable { override fun run() { readingProgress = CompanionConnectionManager.readingProgress() + pendingUpdates = CompanionConnectionManager.pendingUpdates() readerHandler.postDelayed(this, 1000) } } @@ -80,36 +91,53 @@ class MainActivity : ComponentActivity() { private val associationLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult(), ) { result -> + associationPending = false if (result.resultCode == ASSOCIATION_RESULT_OK) { + @Suppress("DEPRECATION") + val chosen = result.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) + val address = when (chosen) { + is android.bluetooth.BluetoothDevice -> chosen.address + is android.bluetooth.le.ScanResult -> chosen.device.address + else -> null + } + address?.let { CompanionConnectionManager.selectApproved(this, it) } refreshAssociations() observeApprovedDevice() connectApprovedDevice() } else { - snapshot = GattSnapshot(GattState.IDLE, "Association cancelled") + snapshot = actionSnapshot(GattState.IDLE, "Association cancelled") } } private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ) { results -> - if (results.isNotEmpty() && results.values.all { it }) connectApprovedDevice() + if (results.isNotEmpty() && results.values.all { it }) { + if (approvedAddress() == null) beginAssociation() else connectApprovedDevice() + } else snapshot = actionSnapshot(GattState.FAULT, "Allow Nearby devices, then try again") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT), + ) CompanionConnectionManager.initialize(this) - nfcAdapter = (getSystemService(NFC_SERVICE) as NfcManager).defaultAdapter + transfers = ViewModelProvider(this)[CompanionTransferModel::class.java] + nfcAdapter = (getSystemService(NFC_SERVICE) as? NfcManager)?.defaultAdapter nfcPendingIntent = PendingIntent.getActivity( this, 0, Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_MUTABLE, ) refreshAssociations() - requestBluetoothPermissionsIfNeeded() observeApprovedDevice() setContent { ZectrixCompanionScreen() } + if (savedInstanceState == null) handleNfcIntent(intent) } override fun onStart() { super.onStart() + refreshAssociations() CompanionConnectionManager.observe(connectionObserver) readerTick.run() } @@ -124,14 +152,14 @@ class MainActivity : ComponentActivity() { addDataType(NfcEnrollmentParser.MIME_TYPE) }, ) - if (intent != null) { + if (intent != null) try { adapter.enableForegroundDispatch(this, intent, filters, nfcTechLists) - } + } catch (_: IllegalStateException) { } } } override fun onPause() { - nfcAdapter?.disableForegroundDispatch(this) + try { nfcAdapter?.disableForegroundDispatch(this) } catch (_: IllegalStateException) { } super.onPause() } @@ -146,39 +174,40 @@ class MainActivity : ComponentActivity() { handleNfcIntent(intent) } + private fun actionSnapshot(state: GattState, detail: String): GattSnapshot { + actionNotice = detail + return GattSnapshot(state, detail) + } + private fun handleNfcIntent(intent: Intent) { - val messages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) - ?: return - val record = messages - .filterIsInstance() - .flatMap { it.records.asList() } - .firstOrNull { - it.tnf == NdefRecord.TNF_MIME_MEDIA && - it.type.contentEquals(NfcEnrollmentParser.MIME_TYPE.toByteArray(Charsets.US_ASCII)) - } ?: return - val enrollment = NfcEnrollmentParser.parsePayload(record.payload) ?: run { - snapshot = GattSnapshot(GattState.FAULT, "Malformed Note4 enrollment record") + val enrollment = try { NfcEnrollmentIntents.read(intent) } + catch (failure: Exception) { + snapshot = actionSnapshot(GattState.FAULT, failure.message ?: "Malformed NFC message") return - } - val accepted = CompanionConnectionManager.setEnrollmentProof( - enrollment.generation, enrollment.token, - ) - if (accepted) { - snapshot = GattSnapshot( - GattState.IDLE, - "NFC enrollment read. Connect to the Note4 to continue.", - ) - connectApprovedDevice() - } else { - snapshot = GattSnapshot(GattState.FAULT, "Bluetooth transport is not ready") - } + } ?: return + try { + if (enrollment.targetAddress() == null) { + snapshot = actionSnapshot(GattState.FAULT, "This tag has no supported Note4 peripheral address") + return + } + if (!CompanionConnectionManager.setEnrollmentProof(enrollment)) { + snapshot = actionSnapshot(GattState.FAULT, "Could not prepare enrollment securely; tap again") + return + } + snapshot = actionSnapshot(GattState.IDLE, "Note4 tap received. Approve this device and confirm its passkey.") + if (approvedAddress() != null) connectApprovedDevice() else beginAssociation() + } finally { enrollment.token.fill(0) } } private fun beginAssociation() { + actionNotice = null + if (associationPending || requestBluetoothPermissionsIfNeeded()) return val manager = getSystemService(CompanionDeviceManager::class.java) - val filter = BluetoothLeDeviceFilter.Builder() - .setNamePattern(Pattern.compile("Zectrix Note4")) - .build() + val target = CompanionConnectionManager.pendingTapAddress() + val filter = BluetoothLeDeviceFilter.Builder().apply { + if (target == null) setNamePattern(Pattern.compile("Zectrix Note4")) + else setScanFilter(ScanFilter.Builder().setDeviceAddress(target).build()) + }.build() val request = AssociationRequest.Builder() .addDeviceFilter(filter) .setSingleDevice(true) @@ -187,7 +216,7 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") @Deprecated("Used on Android 12") override fun onDeviceFound(intentSender: android.content.IntentSender) { - launchAssociationChooser(intentSender) + if (Build.VERSION.SDK_INT < 33) launchAssociationChooser(intentSender) } override fun onAssociationPending(intentSender: android.content.IntentSender) { @@ -195,23 +224,31 @@ class MainActivity : ComponentActivity() { } override fun onAssociationCreated(associationInfo: android.companion.AssociationInfo) { + associationPending = false + associationInfo.deviceMacAddress?.toString()?.let { CompanionConnectionManager.selectApproved(this@MainActivity, it) } refreshAssociations() observeApprovedDevice() connectApprovedDevice() } override fun onFailure(errorMessage: CharSequence?) { - snapshot = GattSnapshot( + associationPending = false + snapshot = actionSnapshot( GattState.FAULT, "Association failed: ${errorMessage ?: "unknown error"}", ) } } - if (Build.VERSION.SDK_INT >= 33) { - manager.associate(request, mainExecutor, callback) - } else { - @Suppress("DEPRECATION") - manager.associate(request, callback, Handler(Looper.getMainLooper())) + associationPending = true + try { + if (Build.VERSION.SDK_INT >= 33) manager.associate(request, mainExecutor, callback) + else { + @Suppress("DEPRECATION") + manager.associate(request, callback, Handler(Looper.getMainLooper())) + } + } catch (_: Exception) { + associationPending = false + snapshot = actionSnapshot(GattState.FAULT, "Turn on Bluetooth and retry pairing") } } @@ -219,14 +256,16 @@ class MainActivity : ComponentActivity() { associationLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) } - private fun requestBluetoothPermissionsIfNeeded() { - if (Build.VERSION.SDK_INT < 31) return + private fun requestBluetoothPermissionsIfNeeded(): Boolean { + if (Build.VERSION.SDK_INT < 31) return false val required = arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) .filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } if (required.isNotEmpty()) permissionLauncher.launch(required.toTypedArray()) + return required.isNotEmpty() } private fun connectApprovedDevice() { + actionNotice = null if (Build.VERSION.SDK_INT >= 31 && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { requestBluetoothPermissionsIfNeeded() @@ -234,7 +273,7 @@ class MainActivity : ComponentActivity() { } val address = approvedAddress() if (address == null) { - snapshot = GattSnapshot(GattState.IDLE, "Pair a Note4 to continue") + snapshot = actionSnapshot(GattState.IDLE, "Pair a Note4 to continue") return } CompanionConnectionManager.connectApproved(this, address) @@ -242,6 +281,7 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") private fun observeApprovedDevice() { + if (Build.VERSION.SDK_INT < 31) return val address = approvedAddress() ?: return try { getSystemService(CompanionDeviceManager::class.java) @@ -252,24 +292,13 @@ class MainActivity : ComponentActivity() { } private fun refreshAssociations() { - val manager = getSystemService(CompanionDeviceManager::class.java) - associationCount = if (Build.VERSION.SDK_INT >= 33) { - manager.myAssociations.size - } else { - @Suppress("DEPRECATION") - manager.associations.size - } + approvedDevices = ApprovedDevices.addresses(this) + associationCount = approvedDevices.size + approvedAddress()?.let { CompanionConnectionManager.selectApproved(this, it) } } - private fun approvedAddress(): String? { - val manager = getSystemService(CompanionDeviceManager::class.java) - return if (Build.VERSION.SDK_INT >= 33) { - manager.myAssociations.firstOrNull()?.deviceMacAddress?.toString() - } else { - @Suppress("DEPRECATION") - manager.associations.firstOrNull() - } - } + private fun approvedAddress(): String? = selectApprovedPeer(ApprovedDevices.addresses(this), + CompanionConnectionManager.pendingTapAddress(), CompanionConnectionManager.preferredAddress()) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @androidx.compose.runtime.Composable @@ -280,6 +309,7 @@ class MainActivity : ComponentActivity() { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { Column( modifier = Modifier.fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 28.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -290,15 +320,29 @@ class MainActivity : ComponentActivity() { style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) - Spacer(Modifier.height(44.dp)) + Spacer(Modifier.height(24.dp)) ConnectionHero(snapshot) Spacer(Modifier.height(32.dp)) PrimaryAction() + actionNotice?.let { + Spacer(Modifier.height(12.dp)) + Text(it, style = MaterialTheme.typography.bodyMedium) + } + if (approvedDevices.size > 1) approvedDevices.forEach { address -> + androidx.compose.material3.TextButton(onClick = { + CompanionConnectionManager.stop() + CompanionConnectionManager.selectApproved(this@MainActivity, address) + readingProgress = CompanionConnectionManager.readingProgress() + snapshot = actionSnapshot(GattState.IDLE, "Selected $address") + }) { Text(address) } + } readingProgress?.let { progress -> Spacer(Modifier.height(24.dp)) ReadingProgressCard(progress) } - Spacer(Modifier.height(36.dp)) + Spacer(Modifier.height(24.dp)) + CompanionExtras(transfers, CompanionConnectionManager.hasOfflineQueue(), pendingUpdates) + Spacer(Modifier.height(24.dp)) Diagnostics(snapshot, associationCount) } } @@ -369,15 +413,22 @@ class MainActivity : ComponentActivity() { GattState.PAIRING, GattState.VERIFYING_LINK, GattState.NEGOTIATING_PROTOCOL, GattState.SYNCHRONIZING, ) - if (associationCount == 0) { - Button(onClick = ::beginAssociation, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("Pair a Note4") + if (busy) { + FilledTonalButton(onClick = { CompanionConnectionManager.stop() }, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text("Cancel connection") + } + } else if (associationCount == 0 || CompanionConnectionManager.pendingTapAddress() != null && approvedAddress() == null) { + Button(onClick = ::beginAssociation, enabled = !associationPending, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text(if (associationPending) "Approve Note4 pairing…" else "Pair a Note4") } } else if (snapshot.state == GattState.READY) { FilledTonalButton( - onClick = {}, enabled = false, + onClick = { + CompanionConnectionManager.stop() + connectApprovedDevice() + }, modifier = Modifier.fillMaxWidth().height(56.dp), - ) { Text("Connected") } + ) { Text("Reconnect & sync time") } } else { Button( onClick = ::connectApprovedDevice, @@ -397,12 +448,12 @@ class MainActivity : ComponentActivity() { Text("${progress.perMille / 10}.${progress.perMille % 10}% · ${if (progress.largeFont) 24 else 16} px") Spacer(Modifier.height(12.dp)) FilledTonalButton( - enabled = snapshot.state == GattState.READY, + enabled = CompanionConnectionManager.hasOfflineQueue(), onClick = { readerNotice = if (CompanionConnectionManager.sendReadingProgress(progress)) { "Position queued. On Note4, choose Use phone position." } else { - "Could not save the position. Connect and try again." + "Could not save the position. Please try again." } }, ) { Text("Resume this position") } diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/MonochromeCover.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/MonochromeCover.kt new file mode 100644 index 0000000..ebd7f3b --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/MonochromeCover.kt @@ -0,0 +1,40 @@ +package dev.zectrix.note4.companion + +/** Fixed PBM raster, with row-bounded Floyd-Steinberg error diffusion. */ +object MonochromeCover { + const val WIDTH = 400 + const val HEIGHT = 300 + val HEADER = "P4\n400 300\n".toByteArray(Charsets.US_ASCII) + val FILE_SIZE = HEADER.size + WIDTH * HEIGHT / 8 + + fun encode(argb: IntArray): ByteArray { + require(argb.size == WIDTH * HEIGHT) + val output = ByteArray(FILE_SIZE) + HEADER.copyInto(output) + var current = IntArray(WIDTH + 2) + var next = IntArray(WIDTH + 2) + for (y in 0 until HEIGHT) { + for (x in 0 until WIDTH) { + val pixel = argb[y * WIDTH + x] + val alpha = pixel ushr 24 + val luminance = (((pixel ushr 16 and 255) * 299 + (pixel ushr 8 and 255) * 587 + + (pixel and 255) * 114) / 1000 * alpha + 255 * (255 - alpha)) / 255 + val value = (luminance * 16 + current[x + 1]).coerceIn(0, 255 * 16) + val black = value < 128 * 16 + if (black) { + val at = HEADER.size + y * (WIDTH / 8) + x / 8 + output[at] = (output[at].toInt() or (0x80 ushr (x % 8))).toByte() + } + val error = value - if (black) 0 else 255 * 16 + current[x + 2] += error * 7 / 16 + next[x] += error * 3 / 16 + next[x + 1] += error * 5 / 16 + next[x + 2] += error / 16 + } + val previous = current + current = next + next = previous.apply { fill(0) } + } + return output + } +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/NfcEnrollmentIntents.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/NfcEnrollmentIntents.kt new file mode 100644 index 0000000..43f4f81 --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/NfcEnrollmentIntents.kt @@ -0,0 +1,30 @@ +package dev.zectrix.note4.companion + +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter + +object NfcEnrollmentIntents { + @Suppress("DEPRECATION") + fun read(intent: Intent): CompanionEnrollmentRecord? { + if (intent.action != NfcAdapter.ACTION_NDEF_DISCOVERED && intent.action != NfcAdapter.ACTION_TECH_DISCOVERED) return null + try { + val messages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) ?: return null + require(messages.size in 1..4 && messages.all { it is NdefMessage }) { "Malformed NFC message" } + val records = messages.filterIsInstance().flatMap { it.records.asList() } + require(records.size <= 8) { "Too many NFC records" } + val candidates = records.filter { it.tnf == NdefRecord.TNF_MIME_MEDIA && + it.type.contentEquals(NfcEnrollmentParser.MIME_TYPE.toByteArray(Charsets.US_ASCII)) } + if (candidates.isEmpty()) return null + require(candidates.size == 1) { "Ambiguous Note4 enrollment records; tap again" } + val payload = candidates.single().payload + return try { + requireNotNull(NfcEnrollmentParser.parsePayload(payload)) { "Malformed Note4 enrollment record" } + } finally { payload.fill(0) } + } finally { + // An Activity recreation must not replay the launch token. + intent.removeExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + } + } +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4CompanionService.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4CompanionService.kt index 64e9ee4..13db606 100644 --- a/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4CompanionService.kt +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4CompanionService.kt @@ -11,26 +11,26 @@ class Note4CompanionService : CompanionDeviceService() { @Suppress("DEPRECATION") override fun onDeviceAppeared(associationInfo: AssociationInfo) { Log.i("ZectrixCompanion", "event=presence state=appeared api=modern") - CompanionConnectionManager.connectApproved( - this, associationInfo.deviceMacAddress.toString(), automatic = true, - ) + associationInfo.deviceMacAddress?.toString()?.let { + CompanionConnectionManager.deviceAppeared(this, it) + } } @Suppress("DEPRECATION") override fun onDeviceAppeared(address: String) { Log.i("ZectrixCompanion", "event=presence state=appeared api=legacy") - CompanionConnectionManager.connectApproved(this, address, automatic = true) + CompanionConnectionManager.deviceAppeared(this, address) } @Suppress("DEPRECATION") override fun onDeviceDisappeared(associationInfo: AssociationInfo) { - CompanionConnectionManager.stop() + associationInfo.deviceMacAddress?.toString()?.let(CompanionConnectionManager::deviceDisappeared) Log.i("ZectrixCompanion", "event=presence state=disappeared api=modern") } @Suppress("DEPRECATION") override fun onDeviceDisappeared(address: String) { - CompanionConnectionManager.stop() + CompanionConnectionManager.deviceDisappeared(address) Log.i("ZectrixCompanion", "event=presence state=disappeared api=legacy") } } diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4TransferClient.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4TransferClient.kt new file mode 100644 index 0000000..705a0cf --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/Note4TransferClient.kt @@ -0,0 +1,170 @@ +package dev.zectrix.note4.companion + +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InterruptedIOException +import java.net.HttpURLConnection +import java.net.URI +import java.net.URLEncoder +import java.util.concurrent.atomic.AtomicBoolean + +class TransferEndpoint private constructor(val origin: String) { + companion object { + fun parse(text: String, allowLoopback: Boolean = false): TransferEndpoint { + val uri = URI(text.trim()) + require(uri.scheme == "http" && uri.rawUserInfo == null && uri.rawQuery == null && + uri.rawFragment == null && uri.rawPath in listOf("", "/")) { "Enter the HTTP address shown on Note4" } + val host = requireNotNull(uri.host) { "Enter a local IPv4 address" } + val parts = host.split('.') + require(parts.size == 4 && parts.all { it.matches(Regex("0|[1-9][0-9]{0,2}")) && it.toInt() <= 255 }) { + "Use the numeric address shown on Note4" + } + val ip = parts.map(String::toInt) + require(ip[0] == 10 || (ip[0] == 172 && ip[1] in 16..31) || + (ip[0] == 192 && ip[1] == 168) || (ip[0] == 169 && ip[1] == 254) || + (allowLoopback && ip[0] == 127)) { "Note4 transfer requires a local network address" } + require(uri.port == -1 || uri.port in 1..65535) { "Invalid port" } + return TransferEndpoint("http://$host${if (uri.port == -1) "" else ":${uri.port}"}") + } + } +} + +data class TransferBook(val name: String, val size: Long) +data class TransferLibrary(val books: List, val more: Boolean, val available: Long, val coverSupported: Boolean) +class TransferFailure(val status: Int, message: String) : IOException(message) + +/** User-initiated local transfers. Never follows redirects or retries a mutation. */ +class Note4TransferClient(private val endpoint: TransferEndpoint, code: String, + private val open: (java.net.URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection }) : AutoCloseable { + companion object { + const val BOOK_LIMIT = 4L * 1024 * 1024 + fun validBookName(name: String): Boolean { + val bytes = name.toByteArray(Charsets.UTF_8) + return bytes.size in 1..63 && bytes.toString(Charsets.UTF_8) == name && + name.none { it.code < 32 || it.code == 127 || it == '/' || it == '\\' } && + name.lastIndexOf('.') > 0 && (name.endsWith(".txt", true) || name.endsWith(".epub", true)) + } + private fun encodeName(name: String) = URLEncoder.encode(name, "UTF-8").replace("+", "%20") + } + private val authorization = "Bearer ${code.trim().uppercase()}" + private val cancelled = AtomicBoolean(false) + @Volatile private var active: HttpURLConnection? = null + + init { require(authorization.matches(Regex("Bearer [A-Z2-9]{12}"))) { "Enter the 12-character access code" } } + + fun library(after: String? = null): TransferLibrary { + require(after == null || validBookName(after)) + val body = JSONObject(request("GET", "/api/books" + (after?.let { "?after=${encodeName(it)}" } ?: "")).toString(Charsets.UTF_8)) + val files = body.getJSONArray("files") + require(files.length() <= 32) { "Note4 returned too many books" } + val books = (0 until files.length()).map { index -> + val item = files.getJSONObject(index) + TransferBook(item.getString("name"), item.getLong("size")).also { + require(validBookName(it.name) && it.size in 0..0xffff_ffffL) + } + } + val more = body.getBoolean("more") + require(books.map { it.name }.distinct().size == books.size && (!more || books.isNotEmpty())) + require(after == null || books.none { it.name == after }) + val available = body.getLong("available") + require(available in 0..0xffff_ffffL) + return TransferLibrary(books, more, available, body.optBoolean("cover_supported", false)) + } + + fun uploadBook(file: File, name: String, progress: (Long, Long) -> Unit = { _, _ -> }) { + require(validBookName(name)) { "Choose a TXT or EPUB with a filename of at most 63 UTF-8 bytes" } + require(file.isFile && file.length() in 0..BOOK_LIMIT) { "Books must fit within 4 MiB" } + accepted(request("PUT", "/api/books/${encodeName(name)}", file, progress)) + } + + fun uploadCover(file: File, progress: (Long, Long) -> Unit = { _, _ -> }) { + require(file.length() == MonochromeCover.FILE_SIZE.toLong()) { "Invalid prepared picture" } + file.inputStream().use { + val header = ByteArray(MonochromeCover.HEADER.size) + java.io.DataInputStream(it).readFully(header) + require(header.contentEquals(MonochromeCover.HEADER)) + } + accepted(request("PUT", "/api/cover", file, progress)) + } + + fun removeCover() { accepted(request("DELETE", "/api/cover")) } + fun finish() { accepted(request("POST", "/api/finish")) } + + private fun accepted(bytes: ByteArray) { + require(JSONObject(bytes.toString(Charsets.UTF_8)).getBoolean("ok")) { "Note4 did not confirm the operation" } + } + + private fun checkActive(started: Long) { + if (cancelled.get() || Thread.currentThread().isInterrupted) throw InterruptedIOException("Transfer cancelled; check the library before retrying") + if ((System.nanoTime() - started) / 1_000_000 >= 120_000) throw InterruptedIOException("Transfer timed out; check the library before retrying") + } + + private fun request(method: String, path: String, file: File? = null, + progress: (Long, Long) -> Unit = { _, _ -> }): ByteArray { + val started = System.nanoTime() + checkActive(started) + val connection = open(URI(endpoint.origin + path).toURL()) + active = connection + try { + checkActive(started) + connection.instanceFollowRedirects = false + connection.useCaches = false + connection.connectTimeout = 8000 + connection.readTimeout = 15_000 + connection.requestMethod = method + connection.setRequestProperty("Authorization", authorization) + connection.setRequestProperty("Connection", "close") + if (file != null || method == "POST") { + val length = file?.length() ?: 0 + connection.doOutput = true + connection.setFixedLengthStreamingMode(length) + connection.setRequestProperty("Content-Type", "application/octet-stream") + connection.outputStream.use { output -> + if (file != null) file.inputStream().use { input -> + val buffer = ByteArray(8192) + var remaining = length + while (remaining > 0) { + checkActive(started) + val count = input.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) + if (count <= 0) throw IOException("The selected file changed; choose it again") + remaining -= count + if (remaining == 0L && input.read() != -1) throw IOException("The selected file changed; choose it again") + output.write(buffer, 0, count) + progress(length - remaining, length) + } + } + } + } + checkActive(started) + val status = connection.responseCode + val response = ByteArrayOutputStream() + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + stream?.use { input -> + val buffer = ByteArray(1024) + while (true) { + checkActive(started) + val count = input.read(buffer) + if (count < 0) break + if (response.size() + count > 16_384) throw IOException("Note4 returned an oversized response") + response.write(buffer, 0, count) + } + } + if (status !in 200..299) { + val detail = runCatching { JSONObject(response.toString("UTF-8")).optString("error") }.getOrNull() + throw TransferFailure(status, detail?.takeIf { it.isNotBlank() }?.take(240) + ?: "Note4 returned HTTP $status; verify its address and access code") + } + return response.toByteArray() + } finally { + connection.disconnect() + active = null + } + } + + override fun close() { + cancelled.set(true) + active?.disconnect() + } +} diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/ReaderProgress.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/ReaderProgress.kt index 2cce609..bb59e41 100644 --- a/android-companion/app/src/main/java/dev/zectrix/note4/companion/ReaderProgress.kt +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/ReaderProgress.kt @@ -20,7 +20,7 @@ object ReaderProgressCodec { val name = progress.bookId.toByteArray(Charsets.UTF_8) require(name.toString(Charsets.UTF_8) == progress.bookId) require(name.size in 1..63 && progress.bookId !in setOf(".", "..")) - require(progress.bookId.none { it.code < 0x20 || it == '/' || it == '\\' }) + require(progress.bookId.none { it.code < 0x20 || it.code == 0x7f || it == '/' || it == '\\' }) require(progress.sourceBytes in 0..0xffff_ffffL && progress.offset in 0..0xffff_ffffL) require(progress.chapter in 0..65535 && progress.perMille in 0..1000) val output = ByteBuffer.allocate(15 + name.size).order(ByteOrder.LITTLE_ENDIAN) diff --git a/android-companion/app/src/main/java/dev/zectrix/note4/companion/WeatherSync.kt b/android-companion/app/src/main/java/dev/zectrix/note4/companion/WeatherSync.kt new file mode 100644 index 0000000..45a297c --- /dev/null +++ b/android-companion/app/src/main/java/dev/zectrix/note4/companion/WeatherSync.kt @@ -0,0 +1,120 @@ +package dev.zectrix.note4.companion + +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URI +import java.net.URLEncoder +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.CodingErrorAction +import kotlin.math.roundToInt + +// Weather is a dated snapshot. It never changes the clock or implies live conditions. +data class WeatherSnapshot(val place: String, val deciCelsius: Int, val code: Int, + val observedAt: Long, val expiresAt: Long) +object WeatherSync { + const val KEY = 0x0102 + private val codes = setOf(0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, + 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99) + + fun encode(value: WeatherSnapshot): ByteArray { + val name = value.place.toByteArray(Charsets.UTF_8) + require(name.size in 1..48 && name.toString(Charsets.UTF_8) == value.place && + value.place.none { it.code < 32 || it.code == 127 }) + require(value.code in codes && value.deciCelsius in -1000..1000) + require(value.observedAt in 946684800..4102444799L && value.expiresAt > value.observedAt && + value.expiresAt <= 4102444800L && value.expiresAt - value.observedAt <= 21600) + return ByteBuffer.allocate(13 + name.size).order(ByteOrder.LITTLE_ENDIAN) + .put(1).put(value.code.toByte()).putShort(value.deciCelsius.toShort()) + .putInt(value.observedAt.toInt()).putInt(value.expiresAt.toInt()).put(name.size.toByte()).put(name).array() + } + + fun decode(bytes: ByteArray): WeatherSnapshot? = runCatching { + require(bytes.size in 14..61) + val input = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + require(input.get().toInt() == 1) + val code = input.get().toInt() and 255 + val temperature = input.short.toInt() + val observed = input.int.toLong() and 0xffff_ffffL + val expires = input.int.toLong() and 0xffff_ffffL + require((input.get().toInt() and 255) == input.remaining()) + val name = Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(input).toString() + WeatherSnapshot(name, temperature, code, observed, expires).also { require(encode(it).contentEquals(bytes)) } + }.getOrNull() + + fun fresh(value: WeatherSnapshot, now: Long) = now >= value.observedAt - 300 && now < value.expiresAt + fun describe(code: Int): String = when (code) { + 0 -> "Clear" + 1, 2, 3 -> "Cloudy" + 45, 48 -> "Fog" + 51, 53, 55, 56, 57 -> "Drizzle" + 71, 73, 75, 77, 85, 86 -> "Snow" + 95, 96, 99 -> "Thunderstorm" + else -> "Rain" + } +} + +data class WeatherPlace(val name: String, val region: String, val latitude: Double, val longitude: Double) + +/** Explicit city lookup, without location permission or background polling. */ +class PhoneWeatherClient : AutoCloseable { + @Volatile private var active: HttpURLConnection? = null + @Volatile private var cancelled = false + + fun search(city: String): List { + require(city.trim().toByteArray(Charsets.UTF_8).size in 1..80) { "Enter a city name" } + val response = get("https://geocoding-api.open-meteo.com/v1/search?count=5&language=en&format=json&name=" + + URLEncoder.encode(city.trim(), "UTF-8")) + val entries = response.optJSONArray("results") ?: return emptyList() + require(entries.length() <= 5) + return (0 until entries.length()).map { index -> + val item = entries.getJSONObject(index) + val name = item.getString("name") + val latitude = item.getDouble("latitude") + val longitude = item.getDouble("longitude") + require(name.toByteArray(Charsets.UTF_8).size in 1..48 && latitude in -90.0..90.0 && longitude in -180.0..180.0) + WeatherPlace(name, listOf(item.optString("admin1"), item.optString("country")).filter { it.isNotBlank() }.joinToString(", ").take(100), latitude, longitude) + } + } + + fun current(place: WeatherPlace, nowSeconds: Long): WeatherSnapshot { + require(place.latitude in -90.0..90.0 && place.longitude in -180.0..180.0) + val item = get("https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}" + + "¤t=temperature_2m,weather_code&temperature_unit=celsius&timeformat=unixtime&forecast_days=1").getJSONObject("current") + val observed = item.getLong("time") + val temperature = item.getDouble("temperature_2m") + require(temperature.isFinite() && observed in nowSeconds - 7200..nowSeconds + 300) { "Weather service returned an outdated observation" } + return WeatherSnapshot(place.name, (temperature * 10).roundToInt(), item.getInt("weather_code"), + observed, observed + 21600).also { WeatherSync.encode(it) } + } + + private fun get(url: String): JSONObject { + check(!cancelled && !Thread.currentThread().isInterrupted) { "Weather request cancelled" } + val connection = URI(url).toURL().openConnection() as HttpURLConnection + active = connection + try { + check(!cancelled) + connection.instanceFollowRedirects = false + connection.connectTimeout = 8000 + connection.readTimeout = 10000 + connection.useCaches = false + connection.setRequestProperty("Accept", "application/json") + require(connection.responseCode == 200) { "Weather service unavailable; try again later" } + return connection.inputStream.use { input -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(2048) + val deadline = System.nanoTime() + 20_000_000_000L + while (true) { + check(!cancelled && !Thread.currentThread().isInterrupted && System.nanoTime() < deadline) + val read = input.read(buffer) + if (read < 0) break + require(output.size() + read <= 32768) { "Weather response is too large" } + output.write(buffer, 0, read) + } + JSONObject(output.toString("UTF-8")) + } + } finally { active = null; connection.disconnect() } + } + + override fun close() { cancelled = true; active?.disconnect() } +} diff --git a/android-companion/app/src/main/res/xml/network_security_config.xml b/android-companion/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..80bf71f --- /dev/null +++ b/android-companion/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionEndToEndTest.kt b/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionEndToEndTest.kt new file mode 100644 index 0000000..df4ea8a --- /dev/null +++ b/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionEndToEndTest.kt @@ -0,0 +1,187 @@ +package dev.zectrix.note4.companion + +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempDirectory + +class CompanionEndToEndTest { + private class Peer(directory: File) : AutoCloseable { + private val process = ProcessBuilder(requireNotNull(System.getProperty("zectrix.peer")), directory.path) + .redirectError(ProcessBuilder.Redirect.INHERIT).start() + private val input = process.inputStream.bufferedReader() + private val output = process.outputStream.bufferedWriter() + fun command(value: String): String { + output.write(value); output.newLine(); output.flush() + return requireNotNull(input.readLine()) { "C++ peer terminated while processing ${value.substringBefore(' ')}" } + } + override fun close() { + output.close() + if (!process.waitFor(5, TimeUnit.SECONDS)) process.destroyForcibly() + input.close() + } + } + + private fun start(peer: Peer, queue: DurableQueue): CompanionSyncSession { + val own = CompanionProtocol.hex(SyncWire.encodeCursors(queue.cursors())) + val remote = SyncWire.decodeCursors(CompanionProtocol.unhex(peer.command("cursors"))) + assertEquals("0", peer.command("start $own")) + return CompanionSyncSession(queue).also { assertEquals(DurableResult.OK, it.start(remote, 0)) } + } + + private fun receive(session: CompanionSyncSession, receiver: CompanionFragments.Reassembler, packet: ByteArray) { + receiver.accept(packet)?.let { + val frame = CompanionProtocol.decode(it) as CompanionProtocol.DecodeResult.Success + assertTrue(session.receive(frame.frame)) + } + } + + private fun peerPackets(peer: Peer, now: Long, capacity: Int): List { + val output = peer.command("poll $now $capacity") + return if (output == "none") emptyList() else output.split(',').map(CompanionProtocol::unhex) + } + + private fun converge(peer: Peer, queue: DurableQueue, capacity: Int) { + val phone = start(peer, queue) + val reassembly = CompanionFragments.Reassembler() + repeat(100) { tick -> + val now = tick * 10L + phone.poll(now) { frame -> + CompanionFragments.encode(frame, tick + 1, capacity).forEach { + assertTrue(peer.command("packet ${CompanionProtocol.hex(it)}") in listOf("partial", "ok")) + } + true + } + peerPackets(peer, now, capacity).forEach { receive(phone, reassembly, it) } + assertEquals(SyncSessionStatus.ACTIVE, phone.status) + if (phone.converged() && peer.command("converged") == "yes") return + } + fail("C++ and Kotlin did not converge") + } + + @Test(timeout = 60000) fun ndefProofConsumptionPersistenceExpiryAndClockCrossTheLanguageBoundary() { + val root = createTempDirectory("note4-enrollment-e2e-").toFile() + val identity = ByteArray(16) { (it + 1).toByte() } + try { + Peer(root).use { peer -> + fun proof(): ByteArray { + val bytes = CompanionProtocol.unhex(peer.command("ndef")) + assertEquals(0xd2, bytes[0].toInt() and 255) + val mimeLength = bytes[1].toInt() and 255 + assertEquals(NfcEnrollmentParser.MIME_TYPE, bytes.copyOfRange(3, 3 + mimeLength).toString(Charsets.US_ASCII)) + val record = NfcEnrollmentParser.parsePayload(bytes.copyOfRange(3 + mimeLength, bytes.size))!! + assertEquals("12:34:56:78:9A:BC", record.targetAddress()) + val handoff = EnrollmentHandoff().also { assertTrue(it.offer(record, 0)) } + val field = CompanionProtocol.decodeTlvs(handoff.take(record.targetAddress()!!, identity, 1)!!).single() + assertNull(handoff.take(record.targetAddress()!!, identity, 2)) + return field.value + } + val first = CompanionProtocol.hex(proof()) + assertEquals("0", peer.command("field")) + assertEquals("0", peer.command("proof $first 0")) + assertNotEquals("0", peer.command("proof $first 0")) + assertEquals(CompanionProtocol.hex(identity), peer.command("identity")) + val failedSave = CompanionProtocol.hex(proof()) + assertEquals("0", peer.command("field")) + assertNotEquals("0", peer.command("proof $failedSave 1")) + assertNotEquals("0", peer.command("proof $failedSave 0")) + val expired = CompanionProtocol.hex(proof()) + assertEquals("0", peer.command("field")) + peer.command("time 120000") + assertNotEquals("0", peer.command("proof $expired 0")) + val clock = CompanionProtocol.decodeTlvs(CompanionProtocol.optionalClockSample(1800000000123, 28800)).single().value + assertEquals("1800000002623 28800", peer.command("clock ${CompanionProtocol.hex(clock)} 1 2500")) + assertEquals("rejected", peer.command("clock ${CompanionProtocol.hex(clock)} 0 2500")) + assertEquals("rejected", peer.command("clock ${CompanionProtocol.hex(clock)} 1 30001")) + } + Peer(root).use { assertEquals(CompanionProtocol.hex(identity), it.command("identity")) } + } finally { root.deleteRecursively() } + } + + @Test(timeout = 120000) fun readingReplaySurvivesEveryFragmentAndAckCutInBothDirections() { + val root = createTempDirectory("note4-replay-e2e-").toFile() + val reading = ReaderProgress("a".repeat(59) + ".txt", 10000, 0, 256, false, 120) + val first = ReaderProgressCodec.encode(reading)!! + val replacement = ReaderProgressCodec.encode(reading.copy(offset = 512, perMille = 250))!! + try { + for (capacity in listOf(20, 182)) { + val dataCount = CompanionFragments.encode(SyncWire.encode(DurableEntry(257, 1, first), 2), 1, capacity).size + val ackCount = CompanionFragments.encode(SyncWire.encode(DurableEntry(257, 1, first), 2, 0), 1, capacity).size + for (fromPhone in listOf(true, false)) for (cut in 0..dataCount + ackCount) { + val directory = File(root, "$capacity-$fromPhone-$cut").apply { mkdirs() } + val phoneFile = File(directory, "phone.bin") + val deviceDirectory = File(directory, "device") + var queue = DurableQueue(phoneFile).also { assertTrue(it.load()) } + Peer(deviceDirectory).use { peer -> + if (fromPhone) assertTrue(queue.put(DurableEntry(257, 1, first))) + else assertEquals("0", peer.command("put 257 1 ${CompanionProtocol.hex(first)}")) + val phone = start(peer, queue) + val receiver = CompanionFragments.Reassembler() + val data = if (fromPhone) { + var frame: ByteArray? = null + phone.poll(0) { frame = it; true } + CompanionFragments.encode(requireNotNull(frame), 1, capacity) + } else peerPackets(peer, 0, capacity) + assertEquals(dataCount, data.size) + data.take(cut).forEach { + if (fromPhone) peer.command("packet ${CompanionProtocol.hex(it)}") else receive(phone, receiver, it) + } + if (cut >= dataCount) { + val ack = if (fromPhone) peerPackets(peer, 0, capacity) else { + var frame: ByteArray? = null + phone.poll(0) { frame = it; true } + CompanionFragments.encode(requireNotNull(frame), 2, capacity) + } + assertEquals(ackCount, ack.size) + ack.take(cut - dataCount).forEach { + if (fromPhone) receive(phone, receiver, it) else peer.command("packet ${CompanionProtocol.hex(it)}") + } + } + } + // Both volatile sessions die. Only their committed files survive. + queue = DurableQueue(phoneFile).also { assertTrue(it.load()) } + Peer(deviceDirectory).use { peer -> + if (fromPhone) assertTrue(queue.put(DurableEntry(257, 2, replacement))) + else assertEquals("0", peer.command("put 257 2 ${CompanionProtocol.hex(replacement)}")) + converge(peer, queue, capacity) + if (fromPhone) assertEquals("2 ${CompanionProtocol.hex(replacement)}", peer.command("read 257")) + else assertArrayEquals(replacement, queue.incoming(257)!!.payload) + assertTrue(queue.snapshot().isEmpty()) + } + } + } + } finally { root.deleteRecursively() } + } + + @Test(timeout = 60000) fun weatherAndFailedFirmwareSaveUseTheRealDurableAckPath() { + val root = createTempDirectory("note4-weather-e2e-").toFile() + try { + val file = File(root, "phone.bin") + var queue = DurableQueue(file).also { assertTrue(it.load()) } + val weather = WeatherSnapshot("上海", -5, 61, 1800000000, 1800021600) + val bytes = WeatherSync.encode(weather) + assertTrue(queue.enqueue(WeatherSync.KEY, bytes)) + Peer(File(root, "peer")).use { peer -> + val phone = start(peer, queue) + peer.command("fail-save") + phone.poll(0) { frame -> + CompanionFragments.encode(frame, 1, 20).forEach { peer.command("packet ${CompanionProtocol.hex(it)}") } + true + } + val reassembly = CompanionFragments.Reassembler() + peerPackets(peer, 0, 20).forEach { receive(phone, reassembly, it) } + assertEquals(SyncSessionStatus.STORE_ERROR, phone.status) + assertEquals("missing", peer.command("read ${WeatherSync.KEY}")) + } + queue = DurableQueue(file).also { assertTrue(it.load()) } + Peer(File(root, "peer")).use { peer -> + converge(peer, queue, 20) + assertEquals("1 ${CompanionProtocol.hex(bytes)}", peer.command("read ${WeatherSync.KEY}")) + assertEquals("-5 61 上海", peer.command("weather ${CompanionProtocol.hex(bytes)} 1800000010")) + assertEquals("rejected", peer.command("weather ${CompanionProtocol.hex(bytes)} 1800021600")) + assertEquals("rejected", peer.command("weather ${CompanionProtocol.hex(bytes.copyOf().apply { this[13] = 0xff.toByte() })} 1800000010")) + } + } finally { root.deleteRecursively() } + } +} diff --git a/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionQualificationTest.kt b/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionQualificationTest.kt new file mode 100644 index 0000000..4980ecb --- /dev/null +++ b/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionQualificationTest.kt @@ -0,0 +1,113 @@ +package dev.zectrix.note4.companion + +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.path.createTempDirectory + +class CompanionQualificationTest { + private val identity = ByteArray(16) { (it + 11).toByte() } + private val address = "12:34:56:78:9A:BC" + private fun record() = CompanionEnrollmentRecord(1, 1, 1, 0, + CompanionProtocol.unhex("123456789abc"), ByteArray(16) { 7 }, 1, ByteArray(16) { 9 }) + + @Test fun tapBindsTargetExpiresAndNeverReplays() { + val handoff = EnrollmentHandoff() + val source = record() + assertTrue(handoff.offer(source, 500)) + source.token.fill(0) + assertEquals(address, handoff.address) + val payload = CompanionProtocol.decodeTlvs(handoff.take(address.lowercase(), identity, 501)!!).single() + val (_, token, companion) = CompanionProtocol.decodeHelloEnrollmentProof(payload.value)!! + assertArrayEquals(ByteArray(16) { 9 }, token) + assertArrayEquals(identity, companion) + assertNull(handoff.take(address, identity, 502)) + assertTrue(handoff.offer(record(), 500)) + assertThrows(IllegalArgumentException::class.java) { handoff.take("12:34:56:78:9A:BD", identity, 501) } + assertNull(handoff.address) + assertTrue(handoff.offer(record(), 500)) + assertThrows(IllegalArgumentException::class.java) { handoff.take(address, identity, 120500) } + assertNull(handoff.take(address, identity, 120501)) + assertFalse(handoff.offer(record().copy(flags = 0), 0)) + assertFalse(handoff.offer(record().copy(bleRole = 2), 0)) + assertFalse(handoff.offer(record().copy(deviceId = ByteArray(16)), 0)) + assertFalse(handoff.offer(record().copy(bleAddressType = 1), 0)) + assertFalse(handoff.offer(record().copy(token = ByteArray(16)), 0)) + } + + @Test fun associationSelectionDoesNotLeakProofToTheFirstDevice() { + val other = "22:33:44:55:66:77" + assertEquals(address, selectApprovedPeer(listOf(other, address), address, other)) + assertNull(selectApprovedPeer(listOf(other), address, other)) + assertNull(selectApprovedPeer(listOf(address, other), null, null)) + assertEquals(other, selectApprovedPeer(listOf(address, other), null, other)) + assertEquals(address, selectApprovedPeer(listOf(address.lowercase()), null, null)) + assertNull(normalizeAddress("../../sync")) + assertNull(normalizeAddress("null")) + } + + @Test fun offlineReaderAndWeatherSurvivePhoneProcessRestart() { + val root = createTempDirectory("note4-offline-").toFile() + try { + val file = File(root, "queue.bin") + val queue = DurableQueue(file).also { assertTrue(it.load()) } + val reading = ReaderProgress("离线阅读.epub", 10000, 3, 512, false, 200) + assertEquals(DurableResult.OK, queue.accept(DurableEntry(ReaderProgressCodec.SYNC_KEY, 8, ReaderProgressCodec.encode(reading)!!))) + val restarted = DurableQueue(file).also { assertTrue(it.load()) } + assertEquals(reading, ReaderProgressCodec.decode(restarted.incoming(ReaderProgressCodec.SYNC_KEY)!!.payload)) + assertTrue(ReaderProgressCodec.enqueue(restarted, reading)) + assertTrue(restarted.enqueue(WeatherSync.KEY, WeatherSync.encode(WeatherSnapshot("Shanghai", -5, 61, 1800000000, 1800021600)))) + val reopened = DurableQueue(file).also { assertTrue(it.load()) } + assertEquals(2, reopened.snapshot().size) + assertEquals(1L, reopened.snapshot().first { it.key == ReaderProgressCodec.SYNC_KEY }.revision) + assertNull(ReaderProgressCodec.encode(reading.copy(bookId = "bad\u007f.txt"))) + } finally { root.deleteRecursively() } + } + + @Test fun coverConversionHasExactDimensionsPolarityAndBoundedGrayDither() { + val white = MonochromeCover.encode(IntArray(120000) { -1 }) + assertEquals(15011, white.size) + assertArrayEquals(MonochromeCover.HEADER, white.copyOfRange(0, 11)) + assertTrue(white.drop(11).all { it == 0.toByte() }) + val black = MonochromeCover.encode(IntArray(120000) { 0xff000000.toInt() }) + assertTrue(black.drop(11).all { it == 0xff.toByte() }) + assertArrayEquals(white, MonochromeCover.encode(IntArray(120000))) + val gray = MonochromeCover.encode(IntArray(120000) { 0xff808080.toInt() }) + val blackPixels = gray.drop(11).sumOf { Integer.bitCount(it.toInt() and 255) } + assertTrue(blackPixels in 57000..63000) + val corners = IntArray(120000) { -1 }.apply { this[0] = 0xff000000.toInt(); this[lastIndex] = 0xff000000.toInt() } + val encoded = MonochromeCover.encode(corners) + assertEquals(0x80, encoded[11].toInt() and 255) + assertEquals(1, encoded.last().toInt() and 255) + assertThrows(IllegalArgumentException::class.java) { MonochromeCover.encode(IntArray(1)) } + } + + @Test fun localTransferNeverAcceptsPublicHostsCredentialsOrInjectedPaths() { + for (text in listOf("http://10.1.2.3", "http://192.168.4.1/", "http://172.16.3.2:8080")) + assertTrue(TransferEndpoint.parse(text).origin.startsWith("http://")) + for (text in listOf("https://192.168.4.1", "http://example.com", "http://8.8.8.8", "http://127.0.0.1", + "http://192.168.4.1/secret", "http://user:code@192.168.4.1", "http://192.168.4.1?code=a", + "http://192.168.4.1#fragment", "http://192.168.4.1:0", "http://010.0.0.1", "http://[::1]")) { + assertThrows(text, Exception::class.java) { TransferEndpoint.parse(text) } + } + assertTrue(Note4TransferClient.validBookName("中文阅读.epub")) + for (name in listOf("../file.txt", "a/b.txt", "bad\u0000.txt", "bad\u007f.epub", "book.pdf", "a".repeat(64) + ".txt")) + assertFalse(Note4TransferClient.validBookName(name)) + } + + @Test fun weatherRejectsMalformedOrExpiredSnapshots() { + val sample = WeatherSnapshot("上海", -15, 71, 1800000000, 1800021600) + val bytes = WeatherSync.encode(sample) + assertEquals(sample, WeatherSync.decode(bytes)) + assertTrue(WeatherSync.fresh(sample, 1800000000)) + assertFalse(WeatherSync.fresh(sample, 1800021600)) + assertFalse(WeatherSync.fresh(sample, 1799999600)) + assertNull(WeatherSync.decode(bytes.copyOf(bytes.size - 1))) + assertNull(WeatherSync.decode(bytes.copyOf().apply { this[1] = 4 })) + assertNull(WeatherSync.decode(bytes.copyOf().apply { this[13] = 0xff.toByte() })) + assertThrows(IllegalArgumentException::class.java) { WeatherSync.encode(sample.copy(expiresAt = sample.observedAt + 21601)) } + assertThrows(IllegalArgumentException::class.java) { WeatherSync.encode(sample.copy(place = "bad\nplace")) } + } +} diff --git a/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionTransferEndToEndTest.kt b/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionTransferEndToEndTest.kt new file mode 100644 index 0000000..38fa63c --- /dev/null +++ b/android-companion/app/src/test/java/dev/zectrix/note4/companion/CompanionTransferEndToEndTest.kt @@ -0,0 +1,97 @@ +package dev.zectrix.note4.companion + +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import java.io.IOException +import java.net.ServerSocket +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.io.path.createTempDirectory + +class CompanionTransferEndToEndTest { + @Test(timeout = 60000) fun phoneStreamsBooksAndPicturesToProductionFirmwareHttpStorage() { + val root = createTempDirectory("note4-transfer-e2e-").toFile() + val books = File(root, "books").apply { mkdirs() } + val server = ProcessBuilder(requireNotNull(System.getProperty("zectrix.web")), books.path) + .redirectError(ProcessBuilder.Redirect.INHERIT).start() + try { + val ready = requireNotNull(server.inputStream.bufferedReader().readLine()) + val match = Regex("READY (http://127.0.0.1:[0-9]+/) code=([A-Z2-9]+)").matchEntire(ready)!! + val endpoint = TransferEndpoint.parse(match.groupValues[1], allowLoopback = true) + val code = match.groupValues[2] + Note4TransferClient(endpoint, "ZZZZZZZZZZZZ").use { denied -> + assertEquals(401, assertThrows(TransferFailure::class.java) { denied.library() }.status) + } + Note4TransferClient(endpoint, code).use { client -> + assertTrue(client.library().coverSupported) + val text = File(root, "源文件.txt").apply { writeText("跨端流式传书。\n".repeat(7000)) } + client.uploadBook(text, "中文阅读.txt") + assertArrayEquals(text.readBytes(), File(books, "中文阅读.txt").readBytes()) + val original = File(books, "中文阅读.txt").readBytes() + assertEquals(409, assertThrows(TransferFailure::class.java) { client.uploadBook(text, "中文阅读.txt") }.status) + assertArrayEquals(original, File(books, "中文阅读.txt").readBytes()) + val cover = File(root, "picture.pbm").apply { + writeBytes(MonochromeCover.encode(IntArray(120000) { if (it % 400 < 200) -1 else 0xff000000.toInt() })) + } + client.uploadCover(cover) + assertArrayEquals(cover.readBytes(), File(books, ".cover-phone.pbm").readBytes()) + assertEquals(409, assertThrows(TransferFailure::class.java) { client.uploadCover(cover) }.status) + assertTrue(client.library().books.none { it.name.endsWith(".pbm") }) + client.removeCover() + assertFalse(File(books, ".cover-phone.pbm").exists()) + client.uploadCover(cover) + repeat(65) { File(books, "A%03d.txt".format(it)).writeText("A book") } + var after: String? = null + val listed = mutableListOf() + do { + val page = client.library(after) + listed += page.books.map { it.name } + after = if (page.more) page.books.last().name else null + } while (after != null) + assertEquals(66, listed.size) + assertEquals(66, listed.distinct().size) + val interrupted = File(root, "interrupted.txt").apply { writeBytes(ByteArray(256000) { 65 }) } + Note4TransferClient(endpoint, code).use { cancelled -> + assertThrows(IOException::class.java) { + cancelled.uploadBook(interrupted, "interrupted.txt") { _, _ -> cancelled.close() } + } + } + // A new request drains the interrupted server request before taking its lease. + client.library() + assertFalse(File(books, "interrupted.txt").exists()) + assertFalse(File(books, ".upload.part").exists()) + client.uploadBook(interrupted, "interrupted.txt") + assertArrayEquals(interrupted.readBytes(), File(books, "interrupted.txt").readBytes()) + client.finish() + } + assertTrue(server.waitFor(5, TimeUnit.SECONDS)) + assertEquals(0, server.exitValue()) + } finally { + if (server.isAlive) { server.destroy(); if (!server.waitFor(5, TimeUnit.SECONDS)) server.destroyForcibly() } + root.deleteRecursively() + } + } + + @Test(timeout = 10000) fun redirectsCannotForwardTheScreenCodeToAnotherHost() { + ServerSocket(0).use { server -> + var requests = 0 + val worker = thread { + server.accept().use { socket -> + val input = socket.getInputStream().bufferedReader() + while (!input.readLine().isNullOrEmpty()) { } + requests++ + socket.getOutputStream().write(("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:${server.localPort}/elsewhere\r\n" + + "Content-Length: 0\r\nConnection: close\r\n\r\n").toByteArray()) + } + } + val endpoint = TransferEndpoint.parse("http://127.0.0.1:${server.localPort}", allowLoopback = true) + Note4TransferClient(endpoint, "ABCDEFGH2345").use { + assertEquals(302, assertThrows(TransferFailure::class.java) { it.library() }.status) + } + worker.join(2000) + assertFalse(worker.isAlive) + assertEquals(1, requests) + } + } +} diff --git a/apps/Calculator.app.json b/apps/Calculator.app.json new file mode 100644 index 0000000..b21d1e9 --- /dev/null +++ b/apps/Calculator.app.json @@ -0,0 +1,11 @@ +{ + "format_version": 1, + "guest_api": 1, + "name": "Calculator", + "version": "1.0.0", + "author": "Note4", + "entry": "Calculator.lua", + "icon": "Calculator.pbm", + "permissions": ["display", "input"], + "instruction_quota": 10000 +} diff --git a/apps/Calculator.pbm b/apps/Calculator.pbm new file mode 100644 index 0000000..8268056 --- /dev/null +++ b/apps/Calculator.pbm @@ -0,0 +1,19 @@ +P1 +# Original Note4 app icon +16 16 +0 0 1 1 1 1 1 1 1 1 1 1 1 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 0 1 1 1 1 1 1 1 1 0 1 0 0 +0 0 1 0 1 0 0 0 0 0 0 1 0 1 0 0 +0 0 1 0 1 0 0 0 0 0 0 1 0 1 0 0 +0 0 1 0 1 1 1 1 1 1 1 1 0 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 0 1 1 0 1 1 0 1 1 0 1 0 0 +0 0 1 0 1 1 0 1 1 0 1 1 0 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 0 1 1 0 1 1 0 1 1 0 1 0 0 +0 0 1 0 1 1 0 1 1 0 1 1 0 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 1 1 1 1 1 1 1 1 1 1 1 0 0 diff --git a/apps/Flashcards.app.json b/apps/Flashcards.app.json new file mode 100644 index 0000000..c4e5c14 --- /dev/null +++ b/apps/Flashcards.app.json @@ -0,0 +1,11 @@ +{ + "format_version": 1, + "guest_api": 1, + "name": "Flashcards", + "version": "1.0.0", + "author": "Note4", + "entry": "Flashcards.lua", + "icon": "Flashcards.pbm", + "permissions": ["display", "input"], + "instruction_quota": 5000 +} diff --git a/apps/Flashcards.pbm b/apps/Flashcards.pbm new file mode 100644 index 0000000..95afd45 --- /dev/null +++ b/apps/Flashcards.pbm @@ -0,0 +1,35 @@ +P1 +# Original Note4 app icon +32 32 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 +0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 +0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 +0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/components/zectrix_app/include/zectrix_micro_app_controller.h b/components/zectrix_app/include/zectrix_micro_app_controller.h index 48913c9..a053dab 100644 --- a/components/zectrix_app/include/zectrix_micro_app_controller.h +++ b/components/zectrix_app/include/zectrix_micro_app_controller.h @@ -31,12 +31,17 @@ class MicroAppController final { MicroAppScene scene() const { return static_cast(scenes_.current()); } bool busy() const { return scene() == MicroAppScene::Loading; } const storage::BookEntry& entry(std::size_t index) const { return entries_[index]; } + const package::Metadata* metadata(std::size_t index) const { + return metadata_[index].icon_side ? &metadata_[index] : nullptr; + } std::size_t count() const { return count_; } std::size_t rows() const { return count_ + previous_ + more_; } std::size_t selected() const { return selected_; } unsigned page() const { return page_; } bool previous() const { return previous_; } const char* name() const { return name_.data(); } + const char* title() const { return current_.icon_side ? current_.name.data() : name(); } + const package::Metadata& current_metadata() const { return current_; } esp_err_t storage_result() const { return storage_result_; } const runtime::Engine& engine() const { return engine_; } @@ -61,9 +66,12 @@ class MicroAppController final { runtime::Engine engine_; storage::BookFile file_; std::array entries_{}; + std::array metadata_{}; + package::Metadata current_{}; + package::TextValidator source_text_; std::array name_{}; uint8_t* source_ = nullptr; - uint32_t loaded_ = 0; + uint32_t loaded_ = 0, source_size_ = 0, source_offset_ = 0; std::size_t count_ = 0, selected_ = 0; unsigned page_ = 0; esp_err_t storage_result_ = ESP_OK; diff --git a/components/zectrix_app/include/zectrix_sleep_cover.h b/components/zectrix_app/include/zectrix_sleep_cover.h index ad556e9..df32655 100644 --- a/components/zectrix_app/include/zectrix_sleep_cover.h +++ b/components/zectrix_app/include/zectrix_sleep_cover.h @@ -1,6 +1,7 @@ #pragma once #include +#include "sdkconfig.h" #include "zectrix_power_service.h" #include "zectrix_scene_manager.h" @@ -9,11 +10,21 @@ namespace zectrix::app { -enum class SleepCoverStyle : uint8_t { Dashboard, Quote, Blank }; +enum class SleepCoverStyle : uint8_t { Dashboard, Quote, Blank, Picture }; +#if CONFIG_ZECTRIX_ENABLE_BOOK_STORAGE +constexpr unsigned kSleepCoverStyleCount = 4; +#else +constexpr unsigned kSleepCoverStyleCount = 3; +#endif constexpr char kSleepCoverSettingKey[] = "ui.sleep_cover"; constexpr SleepCoverStyle kSleepCoverDefault = SleepCoverStyle::Dashboard; SleepCoverStyle SleepCoverSetting(uint32_t value); +struct SleepCoverImage { + void* context = nullptr; + bool (*read)(void* context, uint32_t offset, void* output, std::size_t size) = nullptr; +}; + struct SleepCoverSnapshot { time::ClockSnapshot clock{}; power::PowerSnapshot power{}; @@ -23,6 +34,7 @@ struct SleepCoverSnapshot { uint16_t progress_per_mille = 0; } reading; bool has_reading = false; + std::array weather_line{}; }; struct SleepCalendar { diff --git a/components/zectrix_app/include/zectrix_strings.inc b/components/zectrix_app/include/zectrix_strings.inc index a0d34ee..e45f90c 100644 --- a/components/zectrix_app/include/zectrix_strings.inc +++ b/components/zectrix_app/include/zectrix_strings.inc @@ -88,7 +88,7 @@ ZECTRIX_TEXT(Apps, "APPS", "应用") ZECTRIX_TEXT(AppsPage, "INSTALLED APPS PAGE %u", "已安装应用 第 %u 页") ZECTRIX_TEXT(AppsPrevious, "< Previous page", "< 上一页") ZECTRIX_TEXT(AppsNext, "Next page >", "下一页 >") -ZECTRIX_TEXT(AppsInstallHint, "Install apps in Tools > USB Manager.", "请在工具 > USB 管理中安装应用。") +ZECTRIX_TEXT(AppsInstallHint, "Install with USB or Send Books Wi-Fi.", "通过 USB 或无线传书安装应用。") ZECTRIX_TEXT(AppsNoTransfer, "App transfer is disabled in this build.", "此固件未启用应用传输。") ZECTRIX_TEXT(AppStorageUnavailable, "APP STORAGE UNAVAILABLE", "应用存储暂不可用") ZECTRIX_TEXT(AppLoading, "OPENING APP", "正在打开应用") @@ -97,6 +97,7 @@ ZECTRIX_TEXT(AppReadFailed, "COULD NOT READ THIS APP", "无法读取此应用") ZECTRIX_TEXT(AppInstructionLimit, "APP TOOK TOO LONG", "应用运行超时,已停止") ZECTRIX_TEXT(AppMemoryLimit, "APP MEMORY LIMIT REACHED", "应用内存不足,已停止") ZECTRIX_TEXT(AppInvalidSource, "COULD NOT LOAD THIS APP", "无法加载此应用") +ZECTRIX_TEXT(AppPermissionDenied, "APP PERMISSION NOT DECLARED", "应用未声明所需权限") ZECTRIX_TEXT(AppTryAnother, "Return to Apps to open another app.", "请返回应用列表,选择其他应用。") ZECTRIX_TEXT(NavAppsReturn, "OK Apps Hold OK Back Hold DOWN Off", "OK 应用列表 长按 OK 返回 长按 DOWN 关机") ZECTRIX_TEXT(Date, "DATE", "日期") @@ -421,3 +422,14 @@ ZECTRIX_TEXT(Idle, "IDLE", "空闲") ZECTRIX_TEXT(ZectrixTerminal, "ZECTRIX POCKET TERMINAL", "ZECTRIX 掌上终端") ZECTRIX_TEXT(OpenSourceEpaper, "OPEN-SOURCE E-PAPER SYSTEM", "开源墨水屏系统") ZECTRIX_TEXT(MitLicense, "MIT LICENSE", "MIT 许可") + +ZECTRIX_TEXT(PhonePicture, "PHONE PICTURE", "手机画报") +ZECTRIX_TEXT(PhonePictureDetail, "Send a picture from the companion app.", "用手机伴侣端推送图片。") +ZECTRIX_TEXT(PhonePictureMissing, "No saved picture", "尚未保存图片") +ZECTRIX_TEXT(WeatherClear, "Clear", "晴") +ZECTRIX_TEXT(WeatherCloudy, "Cloudy", "多云") +ZECTRIX_TEXT(WeatherFog, "Fog", "雾") +ZECTRIX_TEXT(WeatherDrizzle, "Drizzle", "毛毛雨") +ZECTRIX_TEXT(WeatherRain, "Rain", "雨") +ZECTRIX_TEXT(WeatherSnow, "Snow", "雪") +ZECTRIX_TEXT(WeatherStorm, "Storm", "雷雨") diff --git a/components/zectrix_app/zectrix_micro_app_controller.cc b/components/zectrix_app/zectrix_micro_app_controller.cc index 9316c62..0ad3bfc 100644 --- a/components/zectrix_app/zectrix_micro_app_controller.cc +++ b/components/zectrix_app/zectrix_micro_app_controller.cc @@ -1,4 +1,5 @@ #include "zectrix_micro_app_controller.h" +#include "zectrix_app_storage.h" #include #include @@ -10,6 +11,18 @@ namespace zectrix::app { namespace { constexpr SceneId Id(MicroAppScene scene) { return static_cast(scene); } +static_assert(package::kSourceLimit == runtime::kSourceLimit); +static_assert(package::kInstructionLimit == runtime::kInstructionLimit); + +bool ReadMetadata(storage::BookFile& file, package::Metadata& metadata) { + std::array header{}; + package::Metadata candidate; + if (!file.Read(0, header.data(), header.size()) || + !package::ReadHeader(header.data(), header.size(), file.Size(), candidate) || + !file.Read(header.size(), candidate.icon.data(), candidate.IconBytes())) return false; + metadata = candidate; + return true; +} } sdk::Status MicroAppController::Start() { @@ -34,6 +47,15 @@ void MicroAppController::Refresh(const char* cursor, bool previous) { bool more = false; storage_result_ = library_.List(entries_.data(), entries_.size(), &count_, &more, cursor ? boundary.data() : nullptr, previous); + metadata_ = {}; + if (storage_result_ == ESP_OK) { + for (std::size_t i = 0; i < count_; ++i) { + if (!storage::AppStorage::Packaged(entries_[i].name.data())) continue; + // Discovery reads at most 288 bytes per entry and never loads Lua. + storage::BookFile file; + if (library_.Open(entries_[i].name.data(), &file) == ESP_OK) ReadMetadata(file, metadata_[i]); + } + } if (storage_result_ == ESP_OK && !count_ && cursor) { Refresh(); return; } selected_ = 0; if (storage_result_ != ESP_OK) { @@ -74,16 +96,28 @@ bool MicroAppController::OnSceneEvent(void* context, const SceneEvent& event) { void MicroAppController::Open() { engine_.Stop(); ReleaseSource(); + current_ = {}; + source_text_ = {}; storage_result_ = library_.Open(name_.data(), &file_); if (storage_result_ != ESP_OK) return; - if (!file_.Size() || file_.Size() > runtime::kSourceLimit) { + if (storage::AppStorage::Packaged(name_.data())) { + if (!ReadMetadata(file_, current_)) { + storage_result_ = ESP_ERR_INVALID_ARG; + return; + } + source_size_ = current_.source_size; + source_offset_ = current_.SourceOffset(); + } else { + source_size_ = file_.Size(); + } + if (!source_size_ || source_size_ > runtime::kSourceLimit) { storage_result_ = ESP_ERR_INVALID_SIZE; return; } #ifdef ESP_PLATFORM - source_ = static_cast(heap_caps_malloc(file_.Size(), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + source_ = static_cast(heap_caps_malloc(source_size_, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); #else - source_ = static_cast(std::malloc(file_.Size())); + source_ = static_cast(std::malloc(source_size_)); #endif if (!source_) storage_result_ = ESP_ERR_NO_MEM; } @@ -96,7 +130,7 @@ void MicroAppController::ReleaseSource() { std::free(source_); #endif source_ = nullptr; - loaded_ = 0; + loaded_ = source_size_ = source_offset_ = 0; } void MicroAppController::Load() { @@ -104,15 +138,30 @@ void MicroAppController::Load() { scenes_.Replace(Id(MicroAppScene::Error)); return; } - const auto size = std::min(1024, file_.Size() - loaded_); - if (!file_.Read(loaded_, source_ + loaded_, size)) { + const auto size = std::min(1024, source_size_ - loaded_); + if (!file_.Read(source_offset_ + loaded_, source_ + loaded_, size)) { storage_result_ = ESP_FAIL; scenes_.Replace(Id(MicroAppScene::Error)); return; } + if (source_offset_ && !source_text_.Feed(source_ + loaded_, size)) { + storage_result_ = ESP_ERR_INVALID_ARG; + scenes_.Replace(Id(MicroAppScene::Error)); + return; + } loaded_ += size; - if (loaded_ != file_.Size()) return; - const bool started = engine_.Start(source_, loaded_); + if (loaded_ != source_size_) return; + if (source_offset_ && !source_text_.Complete()) { + storage_result_ = ESP_ERR_INVALID_ARG; + scenes_.Replace(Id(MicroAppScene::Error)); + return; + } + runtime::Options options; + if (source_offset_) { + options = {static_cast(current_.instructions), + bool(current_.permissions & package::kDisplay), bool(current_.permissions & package::kInput)}; + } + const bool started = engine_.Start(source_, loaded_, options); ReleaseSource(); if (started && engine_.exit_requested()) { engine_.Stop(); diff --git a/components/zectrix_app/zectrix_sleep_cover.cc b/components/zectrix_app/zectrix_sleep_cover.cc index 21079b7..303e8b2 100644 --- a/components/zectrix_app/zectrix_sleep_cover.cc +++ b/components/zectrix_app/zectrix_sleep_cover.cc @@ -3,7 +3,7 @@ namespace zectrix::app { SleepCoverStyle SleepCoverSetting(uint32_t value) { - return value <= static_cast(SleepCoverStyle::Blank) + return value <= static_cast(SleepCoverStyle::Picture) ? static_cast(value) : kSleepCoverDefault; } @@ -41,7 +41,8 @@ const SleepQuote& QuoteForSleep(const SleepCalendar& calendar) { sdk::Status SleepCoverController::Start(SleepCoverStyle selected) { if (scenes_.depth()) return sdk::Status::InvalidState; const auto result = scenes_.Start(0); - scenes_.SetState(0, static_cast(SleepCoverSetting(static_cast(selected)))); + const auto normalized = static_cast(SleepCoverSetting(static_cast(selected))); + scenes_.SetState(0, normalized < kSleepCoverStyleCount ? normalized : 0); action_ = SleepCoverDecision::None; dirty_ = quality_ = false; return result; @@ -70,7 +71,7 @@ bool SleepCoverController::Event(void* context, const SceneEvent& event) { } else if (self.scene() == SleepCoverScene::Choose && (key == Navigation::Previous || key == Navigation::Next)) { const auto selected = self.scenes_.state(0); - self.scenes_.SetState(0, MoveSelection(selected, 3, key)); + self.scenes_.SetState(0, MoveSelection(selected, kSleepCoverStyleCount, key)); self.dirty_ = true; } else return false; return true; diff --git a/components/zectrix_app/zectrix_usb_manager.cc b/components/zectrix_app/zectrix_usb_manager.cc index d945111..7ad3065 100644 --- a/components/zectrix_app/zectrix_usb_manager.cc +++ b/components/zectrix_app/zectrix_usb_manager.cc @@ -35,7 +35,7 @@ Status UsbSettings::Set(Setting key, uint32_t value) { if (value > 1) return Status::Invalid; return storage_.SetUInt32(kAutoShowcaseSettingKey, value) == ESP_OK ? Status::Ok : Status::IoError; case Setting::SleepCover: - if (value > static_cast(SleepCoverStyle::Blank)) return Status::Invalid; + if (value > static_cast(SleepCoverStyle::Picture)) return Status::Invalid; cover_ = static_cast(value); cover_saved_ = storage_.SetUInt32(kSleepCoverSettingKey, value) == ESP_OK; return cover_saved_ ? Status::Ok : Status::NotSaved; diff --git a/components/zectrix_companion/include/zectrix_weather_sync.h b/components/zectrix_companion/include/zectrix_weather_sync.h new file mode 100644 index 0000000..62279fa --- /dev/null +++ b/components/zectrix_companion/include/zectrix_weather_sync.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +namespace zectrix::companion { + +constexpr uint16_t kWeatherSyncKey = 0x0102; +struct WeatherSnapshot { + std::array place{}; + int16_t deci_celsius = 0; + uint8_t code = 0; + uint32_t observed_at = 0, expires_at = 0; + bool Fresh(int64_t now) const { + return now >= static_cast(observed_at) - 300 && now < expires_at; + } +}; + +inline bool DecodeWeatherSnapshot(const uint8_t* data, std::size_t size, WeatherSnapshot* output) { + if (!data || !output || size < 14 || size > 61 || data[0] != 1 || data[12] != size - 13) return false; + constexpr uint8_t codes[] = {0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, + 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99}; + bool valid_code = false; + for (const auto code : codes) if (data[1] == code) valid_code = true; + if (!valid_code) return false; + const auto read32 = [](const uint8_t* p) { + return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; + }; + WeatherSnapshot value; + value.code = data[1]; + const int raw = data[2] | unsigned(data[3]) << 8; + value.deci_celsius = raw >= 32768 ? raw - 65536 : raw; + value.observed_at = read32(data + 4); + value.expires_at = read32(data + 8); + if (value.deci_celsius < -1000 || value.deci_celsius > 1000 || value.observed_at < 946684800U || + value.expires_at <= value.observed_at || value.expires_at > 4102444800U || + value.expires_at - value.observed_at > 21600) return false; + // Labels are bounded UTF-8 text, never control sequences or unterminated input. + for (std::size_t i = 13; i < size; ++i) { + const uint8_t c = data[i]; + if (c < 32 || c == 127) return false; + if (c < 128) continue; + const unsigned extra = c >= 0xc2 && c <= 0xdf ? 1 : c >= 0xe0 && c <= 0xef ? 2 : c >= 0xf0 && c <= 0xf4 ? 3 : 0; + if (!extra || extra >= size - i) return false; + uint32_t cp = c & (extra == 1 ? 31 : extra == 2 ? 15 : 7); + for (unsigned n = 0; n < extra; ++n) { + if ((data[++i] & 0xc0) != 0x80) return false; + cp = cp << 6 | (data[i] & 63); + } + if (cp < (extra == 1 ? 0x80U : extra == 2 ? 0x800U : 0x10000U) || + cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return false; + } + std::memcpy(value.place.data(), data + 13, size - 13); + *output = value; + return true; +} + +} // namespace zectrix::companion diff --git a/components/zectrix_connectivity/include/zectrix_book_web.h b/components/zectrix_connectivity/include/zectrix_book_web.h index 48c4f50..c0a92d7 100644 --- a/components/zectrix_connectivity/include/zectrix_book_web.h +++ b/components/zectrix_connectivity/include/zectrix_book_web.h @@ -31,9 +31,10 @@ class BookWebApi { BookTransferProgress Progress() const; private: - bool List(BookHttpRequest& request, const char* after); - bool Upload(BookHttpRequest& request, const char* name); - bool Download(BookHttpRequest& request, const char* name); + enum class ContentKind : uint8_t { Book, App, Cover }; + bool List(BookHttpRequest& request, const char* after, bool application); + bool Upload(BookHttpRequest& request, const char* name, ContentKind kind); + bool Download(BookHttpRequest& request, const char* name, ContentKind kind); bool Expired(const BookHttpRequest& request, uint32_t started) const; storage::BookStorage* books_ = nullptr; std::array authorization_{}; diff --git a/components/zectrix_connectivity/web/books.html b/components/zectrix_connectivity/web/books.html index c326aa5..2ecb03c 100644 --- a/components/zectrix_connectivity/web/books.html +++ b/components/zectrix_connectivity/web/books.html @@ -20,7 +20,7 @@
Note4
LOCAL WI-FI
-

A shelf for your next chapter

Your pocket library.

Send books to your Note4, then settle into a quiet page.

+

A shelf for your next chapter

Your pocket library.

Bring your books and pocket apps to Note4.

Connect to your reader

Enter the access code on your Note4's Send Books screen. In hotspot mode, it is also the Wi-Fi password.

@@ -29,29 +29,31 @@

Connect to your reader

Enter the access code on you

Made for a little more reading.Wi-Fi switches off after you finish or leave the session idle.
-

Delete this book?

This removes the file from your Note4. Keep a copy on your computer if you want to read it again.

+

Delete this file?

This removes the file from your Note4. Keep a copy on your computer if you want to use it again.

diff --git a/components/zectrix_connectivity/zectrix_book_web.cc b/components/zectrix_connectivity/zectrix_book_web.cc index 86ca2ef..c6e8ff5 100644 --- a/components/zectrix_connectivity/zectrix_book_web.cc +++ b/components/zectrix_connectivity/zectrix_book_web.cc @@ -1,5 +1,8 @@ #include "zectrix_book_web.h" #include "zectrix_book_storage.h" +#include "zectrix_app_storage.h" +#include "zectrix_cover_image.h" +#include "sdkconfig.h" #include #include @@ -9,21 +12,28 @@ extern "C" const char zectrix_book_web_html[]; namespace zectrix::connectivity { namespace { +#if CONFIG_ZECTRIX_ENABLE_RUNTIME +constexpr bool kAppsEnabled = true; +#else +constexpr bool kAppsEnabled = false; +#endif bool Reply(BookHttpRequest& request, int status, const char* message) { return request.Respond(status, "application/json; charset=utf-8") && request.Write(message, std::strlen(message)) && request.Finish(); } -bool WriteResult(BookHttpRequest& request, storage::BookWriteResult result) { +bool WriteResult(BookHttpRequest& request, storage::BookWriteResult result, bool application = false) { using Result = storage::BookWriteResult; switch (result) { case Result::Ok: return Reply(request, 200, "{\"ok\":true}"); - case Result::Invalid: return Reply(request, 400, "{\"error\":\"Use a UTF-8 TXT or EPUB filename of at most 63 bytes.\"}"); - case Result::Busy: return Reply(request, 409, "{\"error\":\"Book storage is busy.\"}"); - case Result::Exists: return Reply(request, 409, "{\"error\":\"This name already exists. Rename the new file or delete the old book first.\"}"); - case Result::NotFound: return Reply(request, 404, "{\"error\":\"Book not found.\"}"); - case Result::NoSpace: return Reply(request, 507, "{\"error\":\"Not enough book storage. Delete a book and try again.\"}"); - case Result::IoError: return Reply(request, 500, "{\"error\":\"Book storage failed. The upload was not installed.\"}"); + case Result::Invalid: return Reply(request, 400, application ? + "{\"error\":\"Invalid app name or package. Use a compatible .zapp or .lua file with a name of at most 47 UTF-8 bytes.\"}" : + "{\"error\":\"Use a UTF-8 TXT or EPUB filename of at most 63 bytes.\"}"); + case Result::Busy: return Reply(request, 409, "{\"error\":\"Content storage is busy.\"}"); + case Result::Exists: return Reply(request, 409, "{\"error\":\"This name already exists. Rename the new file or delete the old file first.\"}"); + case Result::NotFound: return Reply(request, 404, "{\"error\":\"File not found.\"}"); + case Result::NoSpace: return Reply(request, 507, "{\"error\":\"Not enough storage. Delete a file and try again.\"}"); + case Result::IoError: return Reply(request, 500, "{\"error\":\"Storage failed. The upload was not installed.\"}"); } return false; } @@ -35,7 +45,7 @@ int Hex(char c) { return -1; } -bool DecodeName(const char* encoded, char* output) { +bool DecodeName(const char* encoded, char* output, bool application) { std::size_t used = 0; while (*encoded) { if (used == 63) return false; @@ -49,7 +59,7 @@ bool DecodeName(const char* encoded, char* output) { output[used++] = static_cast(value); } output[used] = 0; - return storage::BookStorage::ValidName(output); + return application ? storage::AppStorage::ValidName(output) : storage::BookStorage::ValidName(output); } void JsonName(const char* input, char* output) { @@ -102,32 +112,49 @@ bool BookWebApi::Handle(BookHttpRequest& request) { finish_ = true; return sent; } - constexpr char listing[] = "/api/books", page[] = "/api/books?after=", file[] = "/api/books/"; + if (std::strcmp(request.uri, "/api/cover") == 0) { + if (request.method == BookHttpMethod::Put) + return Upload(request, storage::cover::kName, ContentKind::Cover); + if (request.content_length) return Reply(request, 400, "{\"error\":\"Unexpected request body.\"}"); + if (request.method == BookHttpMethod::Get) + return Download(request, storage::cover::kName, ContentKind::Cover); + if (request.method == BookHttpMethod::Delete) return WriteResult(request, books_->RemoveCover()); + return Reply(request, 405, "{\"error\":\"Method not allowed.\"}"); + } + const bool application = std::strncmp(request.uri, "/api/apps", 9) == 0; + if (application && !kAppsEnabled) return Reply(request, 503, "{\"error\":\"Apps are disabled in this firmware.\"}"); + const char* base = application ? "/api/apps" : "/api/books"; + const auto prefix = std::strlen(base); + if (std::strncmp(request.uri, base, prefix) != 0) return Reply(request, 404, "{\"error\":\"Page not found.\"}"); + const char* tail = request.uri + prefix; char name[64]{}; if (request.method == BookHttpMethod::Get) { - if (std::strcmp(request.uri, listing) == 0) return List(request, nullptr); - if (std::strncmp(request.uri, page, sizeof(page) - 1) == 0 && DecodeName(request.uri + sizeof(page) - 1, name)) - return List(request, name); + if (!*tail) return List(request, nullptr, application); + if (std::strncmp(tail, "?after=", 7) == 0) { + if (!DecodeName(tail + 7, name, application)) return WriteResult(request, storage::BookWriteResult::Invalid, application); + return List(request, name, application); + } } - if (std::strncmp(request.uri, file, sizeof(file) - 1) != 0) - return Reply(request, 404, "{\"error\":\"Page not found.\"}"); - if (!DecodeName(request.uri + sizeof(file) - 1, name)) return WriteResult(request, storage::BookWriteResult::Invalid); + if (*tail != '/') return Reply(request, 404, "{\"error\":\"Page not found.\"}"); + if (!DecodeName(tail + 1, name, application)) return WriteResult(request, storage::BookWriteResult::Invalid, application); switch (request.method) { - case BookHttpMethod::Put: return Upload(request, name); - case BookHttpMethod::Get: return Download(request, name); + case BookHttpMethod::Put: return Upload(request, name, application ? ContentKind::App : ContentKind::Book); + case BookHttpMethod::Get: return Download(request, name, application ? ContentKind::App : ContentKind::Book); case BookHttpMethod::Delete: if (request.content_length) return Reply(request, 400, "{\"error\":\"Unexpected request body.\"}"); - return WriteResult(request, books_->Remove(name)); + return WriteResult(request, application ? storage::AppStorage(*books_).Remove(name) : books_->Remove(name), application); default: return Reply(request, 405, "{\"error\":\"Method not allowed.\"}"); } } -bool BookWebApi::List(BookHttpRequest& request, const char* after) { +bool BookWebApi::List(BookHttpRequest& request, const char* after, bool application) { std::array books{}; std::size_t count = 0; bool more = false; storage::BookSpace space; - if (books_->List(books.data(), books.size(), &count, &more, after) != ESP_OK || books_->Space(&space) != ESP_OK) + const auto listed = application ? storage::AppStorage(*books_).List(books.data(), books.size(), &count, &more, after) + : books_->List(books.data(), books.size(), &count, &more, after); + if (listed != ESP_OK || books_->Space(&space) != ESP_OK) return WriteResult(request, storage::BookWriteResult::IoError); if (!request.Respond(200, "application/json; charset=utf-8") || !request.Write("{\"files\":[", 10)) return false; char buffer[256], name[128]; @@ -137,17 +164,23 @@ bool BookWebApi::List(BookHttpRequest& request, const char* after) { i ? "," : "", name, static_cast(books[i].size)); if (!request.Write(buffer, size)) return false; } - const int size = std::snprintf(buffer, sizeof(buffer), "],\"more\":%s,\"available\":%lu,\"total\":%lu,\"used\":%lu}", + const int size = std::snprintf(buffer, sizeof(buffer), "],\"more\":%s,\"available\":%lu,\"total\":%lu,\"used\":%lu,\"apps_supported\":%s,\"cover_supported\":true}", more ? "true" : "false", static_cast(space.available), - static_cast(space.total), static_cast(space.used)); + static_cast(space.total), static_cast(space.used), kAppsEnabled ? "true" : "false"); return request.Write(buffer, size) && request.Finish(); } -bool BookWebApi::Upload(BookHttpRequest& request, const char* name) { - if (request.content_length > 0x400000) return Reply(request, 413, "{\"error\":\"This book is too large for Note4 storage.\"}"); +bool BookWebApi::Upload(BookHttpRequest& request, const char* name, ContentKind kind) { + const bool application = kind == ContentKind::App; + const bool cover = kind == ContentKind::Cover; + const auto limit = cover ? storage::cover::kFileSize : application ? storage::AppStorage::SizeLimit(name) : 0x400000; + if (cover && request.content_length != storage::cover::kFileSize) + return Reply(request, 400, "{\"error\":\"Use a 400 x 300 binary PBM from the companion app.\"}"); + if (request.content_length > limit) return Reply(request, 413, "{\"error\":\"This file exceeds its Note4 size limit.\"}"); storage::BookUpload upload; - const auto begun = books_->BeginUpload(name, request.content_length, &upload); - if (begun != storage::BookWriteResult::Ok) return WriteResult(request, begun); + const auto begun = cover ? books_->BeginCoverUpload(request.content_length, &upload) : application ? storage::AppStorage(*books_).BeginUpload(name, request.content_length, &upload) + : books_->BeginUpload(name, request.content_length, &upload); + if (begun != storage::BookWriteResult::Ok) return WriteResult(request, begun, application); received_ = 0; expected_ = request.content_length; const auto started = request.NowMs(); @@ -158,9 +191,13 @@ bool BookWebApi::Upload(BookHttpRequest& request, const char* name) { const auto capacity = std::min(buffer.size(), remaining); const int read = request.Read(buffer.data(), capacity); if (read <= 0 || static_cast(read) > capacity) - return Reply(request, 408, "{\"error\":\"Upload interrupted. Please send the book again.\"}"); + return Reply(request, 408, "{\"error\":\"Upload interrupted. Please send the file again.\"}"); const auto written = upload.Write(buffer.data(), read); - if (written != storage::BookWriteResult::Ok) return WriteResult(request, written); + if (written != storage::BookWriteResult::Ok) { + if (cover && written == storage::BookWriteResult::Invalid) + return Reply(request, 400, "{\"error\":\"Invalid PBM picture. Prepare it again in the companion app.\"}"); + return WriteResult(request, written, application); + } remaining -= read; received_ = request.content_length - remaining; activity_ = request.NowMs(); @@ -172,12 +209,13 @@ bool BookWebApi::Upload(BookHttpRequest& request, const char* name) { activity_ = completed_.load(); ++uploaded_; } - return WriteResult(request, committed); + return WriteResult(request, committed, application); } -bool BookWebApi::Download(BookHttpRequest& request, const char* name) { +bool BookWebApi::Download(BookHttpRequest& request, const char* name, ContentKind kind) { + const bool application = kind == ContentKind::App; storage::BookFile file; - const auto opened = books_->OpenManaged(name, &file); + const auto opened = kind == ContentKind::Cover ? books_->OpenCover(&file, true) : application ? storage::AppStorage(*books_).OpenManaged(name, &file) : books_->OpenManaged(name, &file); if (opened != ESP_OK) return WriteResult(request, opened == ESP_ERR_NOT_FOUND ? storage::BookWriteResult::NotFound : storage::BookWriteResult::IoError); if (!request.Respond(200, "application/octet-stream", name)) return false; diff --git a/components/zectrix_demo_ui/font/zectrix_ui_chinese_font.h b/components/zectrix_demo_ui/font/zectrix_ui_chinese_font.h index 085c39d..ed01997 100644 --- a/components/zectrix_demo_ui/font/zectrix_ui_chinese_font.h +++ b/components/zectrix_demo_ui/font/zectrix_ui_chinese_font.h @@ -6,38 +6,39 @@ constexpr uint16_t kUiChineseCodepoints[] = { 0x3001, 0x3002, 0x4e00, 0x4e09, 0x4e0a, 0x4e0b, 0x4e0d, 0x4e0e, 0x4e13, 0x4e2a, 0x4e2d, 0x4e3a, - 0x4e3b, 0x4e4b, 0x4e5f, 0x4e66, 0x4e8c, 0x4e8e, 0x4e92, 0x4e94, 0x4ec5, 0x4eca, 0x4ecd, 0x4ece, - 0x4ed6, 0x4ee5, 0x4eea, 0x4ef6, 0x4efb, 0x4efd, 0x4f11, 0x4f1a, 0x4f20, 0x4f4d, 0x4f53, 0x4f59, - 0x4f5c, 0x4f60, 0x4f7f, 0x4fbf, 0x4fdd, 0x4fe1, 0x505c, 0x50a8, 0x50cf, 0x5141, 0x5145, 0x5148, - 0x5149, 0x5165, 0x5168, 0x516c, 0x516d, 0x5173, 0x5176, 0x5177, 0x5185, 0x518d, 0x5199, 0x51c6, - 0x51cf, 0x51fa, 0x5206, 0x5217, 0x521b, 0x521d, 0x5230, 0x5237, 0x523b, 0x524d, 0x5269, 0x529f, - 0x52a0, 0x52a1, 0x52a8, 0x533a, 0x534f, 0x5355, 0x5386, 0x53d6, 0x53d7, 0x53d8, 0x53e3, 0x53e5, - 0x53ef, 0x53f7, 0x540c, 0x540d, 0x540e, 0x5411, 0x542f, 0x5468, 0x54cd, 0x5524, 0x5546, 0x5668, - 0x56db, 0x56de, 0x56f4, 0x56fa, 0x56fe, 0x5728, 0x5730, 0x5740, 0x57df, 0x5854, 0x58a8, 0x5907, - 0x590d, 0x5927, 0x5929, 0x5931, 0x5934, 0x597d, 0x59cb, 0x5b50, 0x5b57, 0x5b58, 0x5b89, 0x5b8c, - 0x5bbd, 0x5bc6, 0x5bf9, 0x5bfc, 0x5c04, 0x5c06, 0x5c0f, 0x5c1a, 0x5c31, 0x5c40, 0x5c4f, 0x5c71, - 0x5de5, 0x5df1, 0x5df2, 0x5e38, 0x5e55, 0x5e74, 0x5e76, 0x5e7f, 0x5e93, 0x5e94, 0x5ea6, 0x5eca, - 0x5efa, 0x5f00, 0x5f02, 0x5f0f, 0x5f53, 0x5f55, 0x5f85, 0x5fae, 0x5fd9, 0x6001, 0x6062, 0x606f, - 0x6162, 0x6210, 0x6216, 0x624b, 0x6253, 0x62a4, 0x62a5, 0x62e9, 0x6301, 0x6309, 0x636e, 0x6388, - 0x638c, 0x6392, 0x63a5, 0x63d0, 0x63e1, 0x64a4, 0x64cd, 0x652f, 0x6536, 0x6539, 0x6545, 0x6548, - 0x6570, 0x6574, 0x6587, 0x65ad, 0x65b0, 0x65b9, 0x65e0, 0x65e5, 0x65f6, 0x660e, 0x661f, 0x662f, - 0x663e, 0x666f, 0x6682, 0x66f4, 0x6708, 0x6709, 0x670d, 0x671f, 0x672a, 0x672b, 0x672c, 0x673a, - 0x6743, 0x675f, 0x6784, 0x679c, 0x67e5, 0x683c, 0x6863, 0x6a21, 0x6b21, 0x6b62, 0x6b63, 0x6b64, - 0x6b65, 0x6bb5, 0x6bcf, 0x6c34, 0x6c42, 0x6c60, 0x6ca1, 0x6cd5, 0x6ce8, 0x6d4b, 0x6d4f, 0x6d88, - 0x6dfb, 0x6e05, 0x6e90, 0x6f14, 0x706f, 0x7070, 0x70b9, 0x70ed, 0x7247, 0x7259, 0x72b6, 0x7406, - 0x751f, 0x7528, 0x7531, 0x7535, 0x753b, 0x754c, 0x7559, 0x7565, 0x767d, 0x7684, 0x76d8, 0x76f4, - 0x770b, 0x77e5, 0x7801, 0x786c, 0x786e, 0x793a, 0x7981, 0x79bb, 0x79c1, 0x79d2, 0x79f0, 0x79fb, - 0x7a7a, 0x7a97, 0x7acb, 0x7ae0, 0x7aef, 0x7b2c, 0x7b49, 0x7b56, 0x7b80, 0x7ba1, 0x7c4d, 0x7cfb, - 0x7d20, 0x7ea7, 0x7eaf, 0x7ebf, 0x7ec8, 0x7ecf, 0x7ed3, 0x7ed9, 0x7edc, 0x7edf, 0x7ee7, 0x7eea, - 0x7eed, 0x7ef4, 0x7f51, 0x7f6e, 0x7ffb, 0x8017, 0x8054, 0x8111, 0x81ea, 0x81f3, 0x8272, 0x8282, - 0x8303, 0x83b7, 0x83dc, 0x843d, 0x84dd, 0x884c, 0x8868, 0x88c5, 0x8981, 0x89c8, 0x8a00, 0x8ba1, - 0x8ba4, 0x8ba9, 0x8bae, 0x8bb0, 0x8bb8, 0x8bbe, 0x8bbf, 0x8bc1, 0x8bd5, 0x8bdd, 0x8bed, 0x8bef, - 0x8bf7, 0x8bfb, 0x8c27, 0x8d1f, 0x8d25, 0x8d44, 0x8d70, 0x8d85, 0x8db3, 0x8df3, 0x8eab, 0x8f6c, - 0x8f6e, 0x8f7d, 0x8f93, 0x8fb9, 0x8fbd, 0x8fc7, 0x8fd0, 0x8fd4, 0x8fd8, 0x8fd9, 0x8fdb, 0x8fde, - 0x8ff9, 0x9009, 0x901a, 0x9047, 0x90e8, 0x914d, 0x9192, 0x91cd, 0x91cf, 0x949f, 0x9500, 0x9519, - 0x952e, 0x957f, 0x95ed, 0x95ee, 0x95f2, 0x95f4, 0x9605, 0x9614, 0x9664, 0x968f, 0x9690, 0x969c, - 0x96f6, 0x9700, 0x9759, 0x9762, 0x97f3, 0x9875, 0x9879, 0x9884, 0x9891, 0x98ce, 0x9996, 0x9a8c, - 0x9ed1, 0x9ed8, 0xff08, 0xff09, 0xff0c, 0xff1a, + 0x4e3b, 0x4e4b, 0x4e5f, 0x4e66, 0x4e8c, 0x4e8e, 0x4e91, 0x4e92, 0x4e94, 0x4ec5, 0x4eca, 0x4ecd, + 0x4ece, 0x4ed6, 0x4ee5, 0x4eea, 0x4ef6, 0x4efb, 0x4efd, 0x4f11, 0x4f1a, 0x4f20, 0x4f34, 0x4f4d, + 0x4f53, 0x4f59, 0x4f5c, 0x4f60, 0x4f7f, 0x4fa3, 0x4fbf, 0x4fdd, 0x4fe1, 0x505c, 0x50a8, 0x50cf, + 0x5141, 0x5145, 0x5148, 0x5149, 0x5165, 0x5168, 0x516c, 0x516d, 0x5173, 0x5176, 0x5177, 0x5185, + 0x518d, 0x5199, 0x51c6, 0x51cf, 0x51fa, 0x5206, 0x5217, 0x521b, 0x521d, 0x5230, 0x5237, 0x523b, + 0x524d, 0x5269, 0x529f, 0x52a0, 0x52a1, 0x52a8, 0x533a, 0x534f, 0x5355, 0x5386, 0x53d6, 0x53d7, + 0x53d8, 0x53e3, 0x53e5, 0x53ef, 0x53f7, 0x540c, 0x540d, 0x540e, 0x5411, 0x542f, 0x5468, 0x54cd, + 0x5524, 0x5546, 0x5668, 0x56db, 0x56de, 0x56f4, 0x56fa, 0x56fe, 0x5728, 0x5730, 0x5740, 0x57df, + 0x5854, 0x58a8, 0x58f0, 0x5907, 0x590d, 0x591a, 0x5927, 0x5929, 0x5931, 0x5934, 0x597d, 0x59cb, + 0x5b50, 0x5b57, 0x5b58, 0x5b89, 0x5b8c, 0x5bbd, 0x5bc6, 0x5bf9, 0x5bfc, 0x5c04, 0x5c06, 0x5c0f, + 0x5c1a, 0x5c31, 0x5c40, 0x5c4f, 0x5c71, 0x5de5, 0x5df1, 0x5df2, 0x5e38, 0x5e55, 0x5e74, 0x5e76, + 0x5e7f, 0x5e93, 0x5e94, 0x5ea6, 0x5eca, 0x5efa, 0x5f00, 0x5f02, 0x5f0f, 0x5f53, 0x5f55, 0x5f85, + 0x5fae, 0x5fd9, 0x6001, 0x6062, 0x606f, 0x6162, 0x6210, 0x6216, 0x6240, 0x624b, 0x6253, 0x62a4, + 0x62a5, 0x62e9, 0x6301, 0x6309, 0x636e, 0x6388, 0x638c, 0x6392, 0x63a5, 0x63a8, 0x63d0, 0x63e1, + 0x64a4, 0x64cd, 0x652f, 0x6536, 0x6539, 0x6545, 0x6548, 0x6570, 0x6574, 0x6587, 0x65ad, 0x65b0, + 0x65b9, 0x65e0, 0x65e5, 0x65f6, 0x660e, 0x661f, 0x662f, 0x663e, 0x666f, 0x6674, 0x6682, 0x66f4, + 0x6708, 0x6709, 0x670d, 0x671f, 0x672a, 0x672b, 0x672c, 0x673a, 0x6743, 0x675f, 0x6784, 0x679c, + 0x67e5, 0x683c, 0x6863, 0x6a21, 0x6b21, 0x6b62, 0x6b63, 0x6b64, 0x6b65, 0x6bb5, 0x6bcf, 0x6bdb, + 0x6c34, 0x6c42, 0x6c60, 0x6ca1, 0x6cd5, 0x6ce8, 0x6d4b, 0x6d4f, 0x6d88, 0x6dfb, 0x6e05, 0x6e90, + 0x6f14, 0x706f, 0x7070, 0x70b9, 0x70ed, 0x7247, 0x7259, 0x72b6, 0x7406, 0x751f, 0x7528, 0x7531, + 0x7535, 0x753b, 0x754c, 0x7559, 0x7565, 0x767d, 0x7684, 0x76d8, 0x76f4, 0x770b, 0x77e5, 0x7801, + 0x786c, 0x786e, 0x793a, 0x7981, 0x79bb, 0x79c1, 0x79d2, 0x79f0, 0x79fb, 0x7a7a, 0x7a97, 0x7acb, + 0x7ae0, 0x7aef, 0x7b2c, 0x7b49, 0x7b56, 0x7b80, 0x7ba1, 0x7c4d, 0x7cfb, 0x7d20, 0x7ea7, 0x7eaf, + 0x7ebf, 0x7ec8, 0x7ecf, 0x7ed3, 0x7ed9, 0x7edc, 0x7edf, 0x7ee7, 0x7eea, 0x7eed, 0x7ef4, 0x7f51, + 0x7f6e, 0x7ffb, 0x8017, 0x8054, 0x8111, 0x81ea, 0x81f3, 0x8272, 0x8282, 0x8303, 0x83b7, 0x83dc, + 0x843d, 0x84dd, 0x884c, 0x8868, 0x88c5, 0x8981, 0x89c8, 0x8a00, 0x8ba1, 0x8ba4, 0x8ba9, 0x8bae, + 0x8bb0, 0x8bb8, 0x8bbe, 0x8bbf, 0x8bc1, 0x8bd5, 0x8bdd, 0x8bed, 0x8bef, 0x8bf7, 0x8bfb, 0x8c27, + 0x8d1f, 0x8d25, 0x8d44, 0x8d70, 0x8d85, 0x8db3, 0x8df3, 0x8eab, 0x8f6c, 0x8f6e, 0x8f7d, 0x8f93, + 0x8fb9, 0x8fbd, 0x8fc7, 0x8fd0, 0x8fd4, 0x8fd8, 0x8fd9, 0x8fdb, 0x8fde, 0x8ff9, 0x9001, 0x9009, + 0x901a, 0x9047, 0x90e8, 0x914d, 0x9192, 0x91cd, 0x91cf, 0x949f, 0x9500, 0x9519, 0x952e, 0x957f, + 0x95ed, 0x95ee, 0x95f2, 0x95f4, 0x9605, 0x9614, 0x9650, 0x9664, 0x968f, 0x9690, 0x969c, 0x96e8, + 0x96ea, 0x96f6, 0x96f7, 0x96fe, 0x9700, 0x9759, 0x9762, 0x97f3, 0x9875, 0x9879, 0x9884, 0x9891, + 0x98ce, 0x9996, 0x9a8c, 0x9ed1, 0x9ed8, 0xff08, 0xff09, 0xff0c, 0xff1a, }; constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0c, 0x00, 0x06, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+3001 @@ -58,6 +59,7 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x02, 0x20, 0x02, 0x10, 0x02, 0x08, 0x3f, 0xe0, 0x02, 0x20, 0x02, 0x20, 0x02, 0x20, 0x02, 0x20, 0xff, 0xfc, 0x02, 0x04, 0x02, 0x04, 0x02, 0x04, 0x02, 0x04, 0x02, 0x28, 0x02, 0x10, 0x02, 0x00}, // U+4E66 {0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+4E8C {0x10, 0x00, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x05, 0x00, 0x02, 0x00}, // U+4E8E + {0x10, 0x00, 0x00, 0x3f, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfe, 0x02, 0x00, 0x04, 0x00, 0x04, 0x00, 0x08, 0x40, 0x10, 0x20, 0x20, 0x10, 0x7f, 0xf8, 0x20, 0x08, 0x00, 0x08}, // U+4E91 {0x10, 0x00, 0x00, 0x7f, 0xfc, 0x04, 0x00, 0x04, 0x00, 0x04, 0x00, 0x0f, 0xf0, 0x08, 0x10, 0x08, 0x10, 0x10, 0x10, 0x10, 0x20, 0x1f, 0xe0, 0x00, 0x20, 0x00, 0x40, 0x00, 0x40, 0xff, 0xfe, 0x00, 0x00}, // U+4E92 {0x10, 0x00, 0x00, 0x7f, 0xfc, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x3f, 0xf0, 0x04, 0x10, 0x04, 0x10, 0x04, 0x10, 0x04, 0x10, 0x08, 0x10, 0x08, 0x10, 0x08, 0x10, 0xff, 0xfe, 0x00, 0x00}, // U+4E94 {0x10, 0x08, 0x00, 0x0b, 0xfc, 0x09, 0x04, 0x11, 0x04, 0x11, 0x04, 0x30, 0x88, 0x30, 0x88, 0x50, 0x88, 0x90, 0x50, 0x10, 0x50, 0x10, 0x20, 0x10, 0x20, 0x10, 0x50, 0x10, 0x88, 0x11, 0x04, 0x16, 0x02}, // U+4EC5 @@ -73,12 +75,14 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x08, 0x40, 0x08, 0x40, 0x08, 0x40, 0x10, 0x40, 0x17, 0xfe, 0x30, 0x40, 0x30, 0xe0, 0x50, 0xe0, 0x91, 0x50, 0x11, 0x50, 0x12, 0x48, 0x14, 0x44, 0x18, 0x42, 0x10, 0x40, 0x10, 0x40, 0x10, 0x40}, // U+4F11 {0x10, 0x01, 0x00, 0x01, 0x00, 0x02, 0x80, 0x04, 0x40, 0x08, 0x20, 0x30, 0x18, 0xcf, 0xe6, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xfc, 0x02, 0x00, 0x04, 0x00, 0x08, 0x20, 0x10, 0x10, 0x3f, 0xf8, 0x10, 0x08}, // U+4F1A {0x10, 0x08, 0x40, 0x08, 0x40, 0x08, 0x40, 0x13, 0xf8, 0x10, 0x40, 0x30, 0x80, 0x37, 0xfe, 0x50, 0x80, 0x91, 0x00, 0x13, 0xf8, 0x10, 0x08, 0x11, 0x10, 0x10, 0xa0, 0x10, 0x40, 0x10, 0x20, 0x10, 0x20}, // U+4F20 + {0x10, 0x08, 0x40, 0x0a, 0x44, 0x09, 0x48, 0x11, 0x50, 0x10, 0x40, 0x37, 0xfc, 0x30, 0x40, 0x50, 0x40, 0x90, 0x40, 0x1f, 0xfe, 0x10, 0x40, 0x10, 0x40, 0x10, 0x40, 0x10, 0x40, 0x10, 0x40, 0x10, 0x40}, // U+4F34 {0x10, 0x08, 0x80, 0x08, 0x40, 0x08, 0x40, 0x10, 0x00, 0x17, 0xfc, 0x30, 0x00, 0x30, 0x08, 0x52, 0x08, 0x92, 0x08, 0x11, 0x10, 0x11, 0x10, 0x11, 0x10, 0x11, 0x20, 0x10, 0x20, 0x1f, 0xfe, 0x10, 0x00}, // U+4F4D {0x10, 0x08, 0x40, 0x08, 0x40, 0x08, 0x40, 0x10, 0x40, 0x17, 0xfc, 0x30, 0x40, 0x30, 0xe0, 0x50, 0xe0, 0x91, 0x50, 0x11, 0x50, 0x12, 0x48, 0x15, 0xf4, 0x18, 0x42, 0x10, 0x40, 0x10, 0x40, 0x10, 0x40}, // U+4F53 {0x10, 0x01, 0x00, 0x01, 0x00, 0x02, 0x80, 0x04, 0x40, 0x08, 0x20, 0x10, 0x10, 0x2f, 0xe8, 0xc1, 0x06, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x11, 0x10, 0x11, 0x08, 0x21, 0x04, 0x45, 0x04, 0x02, 0x00}, // U+4F59 {0x10, 0x09, 0x00, 0x09, 0x00, 0x09, 0x00, 0x11, 0xfe, 0x12, 0x80, 0x32, 0x80, 0x34, 0x80, 0x50, 0xf8, 0x90, 0x80, 0x10, 0x80, 0x10, 0x80, 0x10, 0xfc, 0x10, 0x80, 0x10, 0x80, 0x10, 0x80, 0x10, 0x80}, // U+4F5C {0x10, 0x08, 0x80, 0x08, 0x80, 0x08, 0x80, 0x11, 0xfe, 0x11, 0x02, 0x32, 0x04, 0x34, 0x20, 0x50, 0x20, 0x91, 0x28, 0x11, 0x24, 0x12, 0x24, 0x12, 0x22, 0x14, 0x22, 0x10, 0x20, 0x10, 0xa0, 0x10, 0x40}, // U+4F60 {0x10, 0x10, 0x40, 0x10, 0x40, 0x1f, 0xfe, 0x20, 0x40, 0x20, 0x40, 0x67, 0xfc, 0x64, 0x44, 0xa4, 0x44, 0x27, 0xfc, 0x20, 0x40, 0x22, 0x40, 0x21, 0x40, 0x20, 0x80, 0x21, 0x40, 0x22, 0x30, 0x2c, 0x0e}, // U+4F7F + {0x10, 0x10, 0x00, 0x13, 0xf8, 0x12, 0x08, 0x22, 0x08, 0x22, 0x08, 0x62, 0x08, 0x63, 0xf8, 0xa0, 0x00, 0x20, 0x00, 0x27, 0xfc, 0x24, 0x04, 0x24, 0x04, 0x24, 0x04, 0x24, 0x04, 0x27, 0xfc, 0x24, 0x04}, // U+4FA3 {0x10, 0x10, 0x00, 0x17, 0xfe, 0x10, 0x40, 0x27, 0xfc, 0x24, 0x44, 0x64, 0x44, 0x67, 0xfc, 0xa4, 0x44, 0x24, 0x44, 0x27, 0xfc, 0x22, 0x40, 0x21, 0x40, 0x20, 0x80, 0x21, 0x40, 0x22, 0x30, 0x2c, 0x0e}, // U+4FBF {0x10, 0x08, 0x00, 0x0b, 0xf8, 0x0a, 0x08, 0x12, 0x08, 0x12, 0x08, 0x33, 0xf8, 0x30, 0x40, 0x50, 0x40, 0x97, 0xfc, 0x10, 0xe0, 0x11, 0x50, 0x12, 0x48, 0x14, 0x44, 0x18, 0x42, 0x10, 0x40, 0x10, 0x40}, // U+4FDD {0x10, 0x08, 0x40, 0x08, 0x20, 0x0b, 0xfe, 0x10, 0x00, 0x10, 0x00, 0x31, 0xfc, 0x30, 0x00, 0x50, 0x00, 0x91, 0xfc, 0x10, 0x00, 0x10, 0x00, 0x11, 0xfc, 0x11, 0x04, 0x11, 0x04, 0x11, 0xfc, 0x11, 0x04}, // U+4FE1 @@ -147,8 +151,10 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x20, 0x14, 0x20, 0x12, 0x20, 0x10, 0x27, 0xfe, 0x20, 0x10, 0xf8, 0x10, 0x23, 0xd2, 0x22, 0x52, 0x22, 0x52, 0x22, 0x54, 0x23, 0xd4, 0x38, 0x08, 0xe0, 0xea, 0x47, 0x1a, 0x02, 0x26, 0x00, 0x42}, // U+57DF {0x10, 0x21, 0x10, 0x21, 0x10, 0x27, 0xfc, 0x21, 0x10, 0x20, 0x40, 0xf8, 0xa0, 0x21, 0x10, 0x22, 0x08, 0x25, 0xf6, 0x20, 0x00, 0x20, 0x00, 0x3b, 0xf8, 0xe2, 0x08, 0x42, 0x08, 0x03, 0xf8, 0x02, 0x08}, // U+5854 {0x10, 0x00, 0x00, 0x3f, 0xf8, 0x29, 0x28, 0x25, 0x48, 0x3f, 0xf8, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0xff, 0xfe, 0x24, 0x48, 0x42, 0x24, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe}, // U+58A8 + {0x10, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x7f, 0xfc, 0x00, 0x00, 0x3f, 0xf8, 0x21, 0x08, 0x21, 0x08, 0x3f, 0xf8, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x40, 0x00, 0x40, 0x00, 0x80, 0x00}, // U+58F0 {0x10, 0x04, 0x00, 0x04, 0x00, 0x0f, 0xf0, 0x18, 0x20, 0x64, 0x40, 0x03, 0x80, 0x1c, 0x70, 0xe0, 0x0e, 0x1f, 0xf0, 0x11, 0x10, 0x11, 0x10, 0x1f, 0xf0, 0x11, 0x10, 0x11, 0x10, 0x1f, 0xf0, 0x10, 0x10}, // U+5907 {0x10, 0x10, 0x00, 0x10, 0x00, 0x1f, 0xfc, 0x20, 0x00, 0x5f, 0xf0, 0x90, 0x10, 0x1f, 0xf0, 0x10, 0x10, 0x1f, 0xf0, 0x04, 0x00, 0x0f, 0xf0, 0x18, 0x20, 0x64, 0x40, 0x03, 0x80, 0x1c, 0x70, 0xe0, 0x0e}, // U+590D + {0x10, 0x02, 0x00, 0x02, 0x00, 0x07, 0xf0, 0x08, 0x20, 0x38, 0x40, 0x04, 0x80, 0x03, 0x40, 0x0c, 0x80, 0x71, 0xf8, 0x02, 0x08, 0x0c, 0x10, 0x32, 0x20, 0x01, 0x40, 0x01, 0x80, 0x0e, 0x00, 0x70, 0x00}, // U+591A {0x10, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x01, 0x00, 0x02, 0x80, 0x02, 0x80, 0x04, 0x40, 0x04, 0x40, 0x08, 0x20, 0x10, 0x10, 0x20, 0x08, 0xc0, 0x06}, // U+5927 {0x10, 0x00, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x02, 0x80, 0x02, 0x80, 0x04, 0x40, 0x04, 0x40, 0x08, 0x20, 0x10, 0x10, 0x20, 0x08, 0xc0, 0x06}, // U+5929 {0x10, 0x01, 0x00, 0x11, 0x00, 0x11, 0x00, 0x11, 0x00, 0x1f, 0xf8, 0x21, 0x00, 0x41, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x02, 0x80, 0x02, 0x80, 0x04, 0x40, 0x08, 0x20, 0x10, 0x10, 0x60, 0x0c}, // U+5931 @@ -199,6 +205,7 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x23, 0xf8, 0x22, 0x08, 0x23, 0xf8, 0x32, 0x08, 0xab, 0xf8, 0xa0, 0x00, 0xa7, 0xfc, 0xa4, 0xa4, 0x24, 0xa4, 0x27, 0xfc, 0x20, 0x00, 0x23, 0xf8, 0x21, 0x10, 0x20, 0xe0, 0x23, 0x18, 0x2c, 0x06}, // U+6162 {0x10, 0x00, 0x50, 0x00, 0x48, 0x00, 0x40, 0x3f, 0xfe, 0x20, 0x40, 0x20, 0x40, 0x20, 0x44, 0x3e, 0x44, 0x22, 0x44, 0x22, 0x28, 0x22, 0x28, 0x22, 0x12, 0x2a, 0x32, 0x44, 0x4a, 0x40, 0x86, 0x81, 0x02}, // U+6210 {0x10, 0x00, 0x48, 0x00, 0x44, 0x00, 0x40, 0xff, 0xfe, 0x00, 0x40, 0x00, 0x40, 0x3e, 0x44, 0x22, 0x44, 0x22, 0x44, 0x22, 0x28, 0x3e, 0x28, 0x00, 0x12, 0x07, 0x32, 0x78, 0x4a, 0x20, 0x86, 0x03, 0x02}, // U+6216 + {0x10, 0x02, 0x08, 0x07, 0x1c, 0x38, 0xe0, 0x20, 0x80, 0x20, 0x80, 0x3e, 0x80, 0x22, 0xfe, 0x22, 0x88, 0x22, 0x88, 0x3e, 0x88, 0x20, 0x88, 0x20, 0x88, 0x41, 0x08, 0x41, 0x08, 0x82, 0x08, 0x04, 0x08}, // U+6240 {0x10, 0x00, 0x10, 0x00, 0xf8, 0x3f, 0x00, 0x01, 0x00, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x05, 0x00, 0x02, 0x00}, // U+624B {0x10, 0x10, 0x00, 0x10, 0x00, 0x13, 0xfe, 0x10, 0x20, 0xfc, 0x20, 0x10, 0x20, 0x10, 0x20, 0x14, 0x20, 0x18, 0x20, 0x30, 0x20, 0xd0, 0x20, 0x10, 0x20, 0x10, 0x20, 0x10, 0x20, 0x50, 0xa0, 0x20, 0x40}, // U+6253 {0x10, 0x10, 0x40, 0x10, 0x20, 0x10, 0x20, 0x11, 0xfe, 0xfd, 0x02, 0x11, 0x02, 0x11, 0x02, 0x15, 0xfe, 0x19, 0x02, 0x31, 0x00, 0xd1, 0x00, 0x11, 0x00, 0x12, 0x00, 0x12, 0x00, 0x54, 0x00, 0x28, 0x00}, // U+62A4 @@ -211,6 +218,7 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x11, 0x10, 0x09, 0x20, 0x3f, 0xfc, 0x20, 0x04, 0x4f, 0xe8, 0x08, 0x20, 0x0f, 0xe0, 0x00, 0xf0, 0x3f, 0x00, 0x01, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x7f, 0xfc, 0x01, 0x00, 0x05, 0x00, 0x02, 0x00}, // U+638C {0x10, 0x10, 0x90, 0x10, 0x90, 0x10, 0x90, 0x17, 0x9e, 0xf8, 0x90, 0x10, 0x90, 0x10, 0x90, 0x1b, 0x9c, 0x30, 0x90, 0xd0, 0x90, 0x10, 0x90, 0x17, 0x9e, 0x10, 0x90, 0x10, 0x90, 0x50, 0x90, 0x20, 0x90}, // U+6392 {0x10, 0x10, 0x80, 0x10, 0x40, 0x13, 0xfc, 0x10, 0x00, 0xfd, 0x08, 0x10, 0x90, 0x17, 0xfe, 0x10, 0x40, 0x18, 0x40, 0x37, 0xfe, 0xd0, 0x88, 0x11, 0x08, 0x10, 0x90, 0x10, 0x60, 0x51, 0x98, 0x26, 0x04}, // U+63A5 + {0x10, 0x10, 0xa0, 0x10, 0x90, 0x10, 0x80, 0x11, 0xfe, 0xfd, 0x10, 0x13, 0x10, 0x15, 0xfc, 0x11, 0x10, 0x19, 0x10, 0x31, 0xfc, 0xd1, 0x10, 0x11, 0x10, 0x11, 0x10, 0x11, 0xfe, 0x51, 0x00, 0x21, 0x00}, // U+63A8 {0x10, 0x10, 0x00, 0x11, 0xfc, 0x11, 0x04, 0x11, 0x04, 0xfd, 0xfc, 0x11, 0x04, 0x11, 0x04, 0x15, 0xfc, 0x18, 0x00, 0x33, 0xfe, 0xd0, 0x20, 0x11, 0x20, 0x11, 0x3c, 0x11, 0x20, 0x52, 0xa0, 0x24, 0x7e}, // U+63D0 {0x10, 0x20, 0x00, 0x23, 0xfc, 0x22, 0x04, 0x22, 0x04, 0xfb, 0xfc, 0x22, 0x00, 0x23, 0xfc, 0x2a, 0x40, 0x32, 0x88, 0xe3, 0xfc, 0x22, 0x24, 0x22, 0x20, 0x25, 0xfc, 0x24, 0x20, 0xa8, 0x20, 0x53, 0xfe}, // U+63E1 {0x10, 0x22, 0x08, 0x21, 0x08, 0x27, 0xc8, 0x22, 0x10, 0xf4, 0x9e, 0x2f, 0xd4, 0x20, 0x64, 0x27, 0x94, 0x34, 0x94, 0xe7, 0x94, 0x24, 0x94, 0x27, 0x88, 0x24, 0x88, 0x24, 0x94, 0xa4, 0xa4, 0x45, 0xc2}, // U+64A4 @@ -234,6 +242,7 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x1f, 0xf0, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0, 0x00, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x11, 0x00, 0x11, 0xf8, 0x11, 0x00, 0x29, 0x00, 0x45, 0x00, 0x83, 0xfe}, // U+662F {0x10, 0x00, 0x00, 0x1f, 0xf0, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0, 0x04, 0x40, 0x44, 0x44, 0x24, 0x44, 0x14, 0x48, 0x14, 0x50, 0x04, 0x40, 0xff, 0xfe, 0x00, 0x00}, // U+663E {0x10, 0x1f, 0xf0, 0x10, 0x10, 0x1f, 0xf0, 0x10, 0x10, 0x1f, 0xf0, 0x01, 0x00, 0xff, 0xfe, 0x00, 0x00, 0x1f, 0xf0, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0, 0x01, 0x00, 0x21, 0x08, 0x45, 0x04, 0x02, 0x00}, // U+666F + {0x10, 0x00, 0x20, 0x00, 0x20, 0x7b, 0xfe, 0x48, 0x20, 0x49, 0xfc, 0x48, 0x20, 0x4b, 0xfe, 0x78, 0x00, 0x49, 0xfc, 0x49, 0x04, 0x49, 0xfc, 0x49, 0x04, 0x79, 0xfc, 0x49, 0x04, 0x01, 0x14, 0x01, 0x08}, // U+6674 {0x10, 0x10, 0x0c, 0xfe, 0xf0, 0x20, 0x80, 0x48, 0x80, 0x7e, 0xfe, 0x08, 0x88, 0x0e, 0x88, 0xf9, 0x08, 0x4a, 0x08, 0x1f, 0xf8, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0, 0x10, 0x10, 0x10, 0x10, 0x1f, 0xf0}, // U+6682 {0x10, 0x00, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x01, 0x00, 0x3f, 0xf8, 0x21, 0x08, 0x21, 0x08, 0x3f, 0xf8, 0x21, 0x08, 0x21, 0x08, 0x3f, 0xf8, 0x11, 0x00, 0x0a, 0x00, 0x06, 0x00, 0x19, 0xc0, 0xe0, 0x3e}, // U+66F4 {0x10, 0x00, 0x00, 0x0f, 0xf8, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0f, 0xf8, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0f, 0xf8, 0x08, 0x08, 0x08, 0x08, 0x10, 0x08, 0x10, 0x08, 0x20, 0x28, 0x40, 0x10}, // U+6708 @@ -259,6 +268,7 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x01, 0x00, 0x01, 0x00, 0x11, 0xf8, 0x11, 0x00, 0x11, 0x00, 0x11, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x01, 0x00, 0x11, 0x08, 0x11, 0x10, 0x21, 0x20, 0x40, 0xc0, 0x03, 0x00, 0x1c, 0x00, 0xe0, 0x00}, // U+6B65 {0x10, 0x06, 0x00, 0x38, 0xf8, 0x20, 0x88, 0x20, 0x88, 0x20, 0x88, 0x3c, 0x86, 0x21, 0x00, 0x20, 0x00, 0x3d, 0xfc, 0x20, 0x84, 0x20, 0x88, 0x2c, 0x50, 0xf0, 0x20, 0x20, 0x50, 0x20, 0x88, 0x23, 0x06}, // U+6BB5 {0x10, 0x10, 0x00, 0x1f, 0xfc, 0x20, 0x00, 0x20, 0x00, 0x5f, 0xf0, 0x90, 0x10, 0x12, 0x10, 0x11, 0x10, 0xff, 0xfe, 0x20, 0x10, 0x22, 0x10, 0x21, 0x10, 0x3f, 0xfc, 0x00, 0x10, 0x00, 0xa0, 0x00, 0x40}, // U+6BCF + {0x10, 0x00, 0x10, 0x01, 0xf8, 0x7e, 0x00, 0x02, 0x00, 0x02, 0x00, 0x03, 0xf0, 0x3e, 0x00, 0x02, 0x00, 0x02, 0x00, 0x03, 0xfc, 0xfe, 0x00, 0x02, 0x00, 0x02, 0x04, 0x02, 0x04, 0x02, 0x04, 0x01, 0xfc}, // U+6BDB {0x10, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x08, 0x01, 0x08, 0x7d, 0x90, 0x05, 0xa0, 0x09, 0x40, 0x09, 0x40, 0x11, 0x20, 0x11, 0x10, 0x21, 0x08, 0x41, 0x06, 0x81, 0x00, 0x05, 0x00, 0x02, 0x00}, // U+6C34 {0x10, 0x01, 0x20, 0x01, 0x10, 0x01, 0x00, 0x7f, 0xfc, 0x01, 0x00, 0x21, 0x08, 0x11, 0x10, 0x09, 0xa0, 0x03, 0x40, 0x05, 0x20, 0x09, 0x10, 0x11, 0x08, 0x61, 0x06, 0x01, 0x00, 0x05, 0x00, 0x02, 0x00}, // U+6C42 {0x10, 0x00, 0x40, 0x20, 0x40, 0x12, 0x40, 0x12, 0x58, 0x82, 0x68, 0x42, 0xc8, 0x53, 0x48, 0x1e, 0x48, 0x12, 0x48, 0x22, 0x48, 0xe2, 0x58, 0x22, 0x42, 0x22, 0x02, 0x22, 0x02, 0x21, 0xfe, 0x00, 0x00}, // U+6C60 @@ -389,6 +399,7 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x00, 0x90, 0x20, 0x90, 0x10, 0x90, 0x13, 0xfc, 0x00, 0x90, 0x00, 0x90, 0xf0, 0x90, 0x17, 0xfe, 0x10, 0x90, 0x10, 0x90, 0x11, 0x10, 0x11, 0x10, 0x12, 0x10, 0x28, 0x00, 0x47, 0xfe, 0x00, 0x00}, // U+8FDB {0x10, 0x00, 0x40, 0x20, 0x40, 0x17, 0xfe, 0x10, 0x80, 0x00, 0xa0, 0x01, 0x20, 0xf3, 0xfc, 0x10, 0x20, 0x10, 0x20, 0x10, 0x20, 0x17, 0xfe, 0x10, 0x20, 0x10, 0x20, 0x28, 0x20, 0x47, 0xfe, 0x00, 0x00}, // U+8FDE {0x10, 0x00, 0x40, 0x20, 0x20, 0x10, 0x20, 0x17, 0xfe, 0x00, 0x90, 0x00, 0x90, 0xf2, 0x94, 0x12, 0x92, 0x14, 0x92, 0x11, 0x10, 0x11, 0x10, 0x12, 0x50, 0x14, 0x20, 0x28, 0x00, 0x47, 0xfe, 0x00, 0x00}, // U+8FF9 + {0x10, 0x02, 0x08, 0x21, 0x08, 0x11, 0x10, 0x13, 0xfc, 0x00, 0x40, 0x00, 0x40, 0xf0, 0x40, 0x17, 0xfe, 0x10, 0x40, 0x10, 0xa0, 0x10, 0x90, 0x11, 0x08, 0x12, 0x08, 0x28, 0x00, 0x47, 0xfe, 0x00, 0x00}, // U+9001 {0x10, 0x00, 0x40, 0x22, 0x40, 0x12, 0x40, 0x13, 0xf8, 0x04, 0x40, 0x00, 0x40, 0xf7, 0xfc, 0x11, 0x20, 0x11, 0x20, 0x11, 0x20, 0x12, 0x24, 0x12, 0x24, 0x14, 0x1c, 0x28, 0x00, 0x47, 0xfe, 0x00, 0x00}, // U+9009 {0x10, 0x00, 0x00, 0x47, 0xf8, 0x20, 0x10, 0x21, 0xa0, 0x00, 0x40, 0x07, 0xfc, 0xe4, 0x44, 0x24, 0x44, 0x27, 0xfc, 0x24, 0x44, 0x24, 0x44, 0x27, 0xfc, 0x24, 0x44, 0x24, 0x54, 0x54, 0x08, 0x8f, 0xfe}, // U+901A {0x10, 0x00, 0x00, 0x23, 0xf8, 0x12, 0x48, 0x13, 0xf8, 0x02, 0x48, 0x03, 0xf8, 0xf0, 0x40, 0x17, 0xfc, 0x14, 0x44, 0x14, 0x54, 0x14, 0x74, 0x15, 0x94, 0x14, 0x04, 0x14, 0x0c, 0x28, 0x00, 0x47, 0xfe}, // U+9047 @@ -408,11 +419,16 @@ constexpr uint8_t kUiChineseBitmaps[][33] = { {0x10, 0x20, 0x00, 0x13, 0xfc, 0x10, 0x04, 0x40, 0x04, 0x47, 0xc4, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x47, 0xc4, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x47, 0xc4, 0x40, 0x04, 0x40, 0x14, 0x40, 0x08}, // U+95F4 {0x10, 0x20, 0x00, 0x17, 0xfc, 0x00, 0x04, 0x44, 0x44, 0x42, 0x84, 0x40, 0x04, 0x4f, 0xe4, 0x48, 0x24, 0x48, 0x24, 0x4f, 0xe4, 0x42, 0x84, 0x42, 0x84, 0x44, 0x84, 0x48, 0x94, 0x50, 0x74, 0x40, 0x08}, // U+9605 {0x10, 0x20, 0x00, 0x17, 0xfc, 0x00, 0x04, 0x48, 0x34, 0x45, 0xc4, 0x64, 0x44, 0x50, 0x44, 0x51, 0xf4, 0x44, 0x44, 0x48, 0x44, 0x79, 0xf4, 0x49, 0x14, 0x49, 0x14, 0x49, 0xf4, 0x40, 0x04, 0x40, 0x0c}, // U+9614 + {0x10, 0x00, 0x00, 0x7b, 0xf8, 0x4a, 0x08, 0x52, 0x08, 0x53, 0xf8, 0x62, 0x08, 0x52, 0x08, 0x4b, 0xf8, 0x4a, 0x44, 0x4a, 0x48, 0x6a, 0x30, 0x52, 0x20, 0x42, 0x10, 0x42, 0x88, 0x43, 0x06, 0x42, 0x00}, // U+9650 {0x10, 0x00, 0x00, 0x78, 0x40, 0x48, 0x40, 0x50, 0xa0, 0x51, 0x10, 0x62, 0x08, 0x55, 0xf6, 0x48, 0x40, 0x48, 0x40, 0x4b, 0xfc, 0x68, 0x40, 0x51, 0x50, 0x42, 0x48, 0x44, 0x44, 0x41, 0x44, 0x40, 0x80}, // U+9664 {0x10, 0x00, 0x10, 0x78, 0x10, 0x49, 0xfe, 0x54, 0x20, 0x52, 0x40, 0x60, 0xfc, 0x51, 0x44, 0x48, 0x44, 0x4e, 0x7c, 0x4a, 0x44, 0x6a, 0x7c, 0x52, 0x44, 0x42, 0x54, 0x42, 0x48, 0x45, 0x00, 0x48, 0xfe}, // U+968F {0x10, 0x00, 0x80, 0x78, 0x80, 0x49, 0xf8, 0x4a, 0x08, 0x54, 0x10, 0x53, 0xfc, 0x60, 0x04, 0x51, 0xfc, 0x48, 0x04, 0x4b, 0xfc, 0x48, 0x04, 0x68, 0x40, 0x51, 0x24, 0x45, 0x2a, 0x45, 0x0a, 0x48, 0xf8}, // U+9690 {0x10, 0x00, 0x20, 0x7d, 0xfc, 0x44, 0x88, 0x48, 0x50, 0x4b, 0xfe, 0x50, 0x00, 0x49, 0xfc, 0x49, 0x04, 0x45, 0xfc, 0x45, 0x04, 0x45, 0xfc, 0x68, 0x20, 0x53, 0xfe, 0x40, 0x20, 0x40, 0x20, 0x40, 0x20}, // U+969C + {0x10, 0x00, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x7f, 0xfc, 0x41, 0x04, 0x41, 0x04, 0x49, 0x44, 0x45, 0x24, 0x41, 0x04, 0x49, 0x44, 0x45, 0x24, 0x41, 0x04, 0x41, 0x14, 0x40, 0x08}, // U+96E8 + {0x10, 0x3f, 0xf8, 0x01, 0x00, 0x7f, 0xfe, 0x41, 0x02, 0x9d, 0x74, 0x01, 0x00, 0x1d, 0x70, 0x00, 0x00, 0x3f, 0xf8, 0x00, 0x08, 0x00, 0x08, 0x1f, 0xf8, 0x00, 0x08, 0x00, 0x08, 0x3f, 0xf8, 0x00, 0x08}, // U+96EA {0x10, 0x3f, 0xf8, 0x01, 0x00, 0x7f, 0xfe, 0x41, 0x02, 0x9d, 0x74, 0x01, 0x00, 0x1d, 0x70, 0x02, 0x80, 0x0c, 0x60, 0x32, 0x18, 0xc1, 0x06, 0x1f, 0xe0, 0x00, 0x20, 0x06, 0x40, 0x01, 0x80, 0x00, 0x40}, // U+96F6 + {0x10, 0x00, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x7f, 0xfe, 0x41, 0x02, 0x9d, 0x74, 0x01, 0x00, 0x1d, 0x70, 0x00, 0x00, 0x3f, 0xf8, 0x21, 0x08, 0x21, 0x08, 0x3f, 0xf8, 0x21, 0x08, 0x21, 0x08, 0x3f, 0xf8}, // U+96F7 + {0x10, 0x3f, 0xf8, 0x01, 0x00, 0x7f, 0xfe, 0x41, 0x02, 0x9d, 0x74, 0x01, 0x00, 0x1d, 0x70, 0x04, 0x00, 0x0f, 0xe0, 0x14, 0x40, 0x03, 0x80, 0x1c, 0x70, 0xe2, 0x0e, 0x0f, 0xe0, 0x04, 0x20, 0x18, 0x60}, // U+96FE {0x10, 0x00, 0x00, 0x3f, 0xf8, 0x01, 0x00, 0x7f, 0xfe, 0x41, 0x02, 0x9d, 0x74, 0x01, 0x00, 0x1d, 0x70, 0x00, 0x00, 0xff, 0xfe, 0x02, 0x00, 0x3f, 0xf8, 0x24, 0x88, 0x24, 0x88, 0x24, 0xa8, 0x20, 0x10}, // U+9700 {0x10, 0x10, 0x40, 0x10, 0x40, 0xfe, 0x78, 0x10, 0x88, 0x7c, 0x10, 0x11, 0xfc, 0xfe, 0x24, 0x00, 0x24, 0x7d, 0xfe, 0x44, 0x24, 0x7c, 0x24, 0x45, 0xfc, 0x7c, 0x24, 0x44, 0x20, 0x54, 0xa0, 0x48, 0x40}, // U+9759 {0x10, 0x00, 0x00, 0xff, 0xfe, 0x02, 0x00, 0x02, 0x00, 0x04, 0x00, 0x3f, 0xf8, 0x24, 0x48, 0x24, 0x48, 0x27, 0xc8, 0x24, 0x48, 0x24, 0x48, 0x27, 0xc8, 0x24, 0x48, 0x24, 0x48, 0x3f, 0xf8, 0x20, 0x08}, // U+9762 diff --git a/components/zectrix_demo_ui/include/zectrix_demo_ui.h b/components/zectrix_demo_ui/include/zectrix_demo_ui.h index e5cae4c..737026a 100644 --- a/components/zectrix_demo_ui/include/zectrix_demo_ui.h +++ b/components/zectrix_demo_ui/include/zectrix_demo_ui.h @@ -21,7 +21,7 @@ namespace zectrix::app { class MicroAppController; } namespace zectrix::app { class UtilityController; } namespace zectrix::app { class SettingsController; } namespace zectrix::app { class LauncherController; struct ReadingOverview; } -namespace zectrix::app { struct SleepCoverSnapshot; enum class SleepCoverStyle : uint8_t; } +namespace zectrix::app { struct SleepCoverSnapshot; struct SleepCoverImage; enum class SleepCoverStyle : uint8_t; } namespace zectrix::connectivity { struct BookTransferSnapshot; } namespace zectrix::host { struct Snapshot; } @@ -89,7 +89,7 @@ class ZectrixDemoUi { const char* status, bool full_refresh); esp_err_t ShowSleepCover(const zectrix::app::SleepCoverSnapshot& snapshot, zectrix::app::SleepCoverStyle style, bool preview = false, - bool preference_saved = true); + bool preference_saved = true, const zectrix::app::SleepCoverImage* picture = nullptr); ZectrixCanvas& canvas() { return canvas_; } auto InspectViews() const { return viewports_.Inspect(); } diff --git a/components/zectrix_demo_ui/include/zectrix_micro_app_view.h b/components/zectrix_demo_ui/include/zectrix_micro_app_view.h index 7ba7788..420c66c 100644 --- a/components/zectrix_demo_ui/include/zectrix_micro_app_view.h +++ b/components/zectrix_demo_ui/include/zectrix_micro_app_view.h @@ -2,8 +2,11 @@ #include "zectrix_canvas.h" #include "zectrix_runtime.h" +#include "zectrix_app_package.h" namespace zectrix::ui { // Draw only the completed host-owned command buffer, inside the guest viewport. void DrawMicroAppFrame(ZectrixCanvas& canvas, const runtime::Frame& frame); +void DrawMicroAppIcon(ZectrixCanvas& canvas, const package::Metadata& meta, + int x, int y, int side = 16, bool inverse = false); } // namespace zectrix::ui diff --git a/components/zectrix_demo_ui/zectrix_micro_app_ui.cc b/components/zectrix_demo_ui/zectrix_micro_app_ui.cc index c743e53..74ce55c 100644 --- a/components/zectrix_demo_ui/zectrix_micro_app_ui.cc +++ b/components/zectrix_demo_ui/zectrix_micro_app_ui.cc @@ -13,13 +13,13 @@ esp_err_t ZectrixDemoUi::ShowMicroApps(const zectrix::app::MicroAppController& a using i18n::Tr; using app::MicroAppScene; const auto scene = apps.scene(); - DrawFrame(scene == MicroAppScene::List ? Tr(Text::Apps) : apps.name(), + DrawFrame(scene == MicroAppScene::List ? Tr(Text::Apps) : apps.title(), Tr(scene == MicroAppScene::List ? Text::NavOpenBack : scene == MicroAppScene::Error ? Text::NavAppsReturn : Text::NavBackOff)); if (scene == MicroAppScene::List) { if (!apps.count()) { canvas_.TextCentered(100, Tr(apps.storage_result() == ESP_OK ? Text::NoApps : Text::AppStorageUnavailable)); -#if CONFIG_ZECTRIX_ENABLE_USB_HOST +#if CONFIG_ZECTRIX_ENABLE_USB_HOST || CONFIG_ZECTRIX_ENABLE_BOOK_TRANSFER canvas_.TextCentered(146, Tr(Text::AppsInstallHint)); #else canvas_.TextCentered(146, Tr(Text::AppsNoTransfer)); @@ -29,26 +29,46 @@ esp_err_t ZectrixDemoUi::ShowMicroApps(const zectrix::app::MicroAppController& a char page[48]; std::snprintf(page, sizeof(page), Tr(Text::AppsPage), apps.page() + 1); canvas_.Text(16, 46, page); + if (apps.selected() < apps.count()) { + if (const auto* meta = apps.metadata(apps.selected())) + ui::DrawUtf8Line(canvas_, 232, 46, meta->author.data(), 152); + } for (std::size_t i = 0; i < apps.rows(); ++i) { const int y = 70 + i * 27; const bool selected = i == apps.selected(); canvas_.FillRect(12, y, 376, 24, selected); if (!selected) canvas_.Rect(12, y, 376, 24); - const char* label = i < apps.count() ? apps.entry(i).name.data() : + const auto* meta = i < apps.count() ? apps.metadata(i) : nullptr; + const char* label = meta ? meta->name.data() : i < apps.count() ? apps.entry(i).name.data() : Tr(apps.previous() && i == apps.count() ? Text::AppsPrevious : Text::AppsNext); - ui::DrawUtf8Line(canvas_, 20, y + 4, label, 360, selected); + if (meta) { + ui::DrawMicroAppIcon(canvas_, *meta, 20, y + 4, 16, selected); + ui::DrawUtf8Line(canvas_, 44, y + 4, label, 240, selected); + ui::DrawUtf8Line(canvas_, 292, y + 4, meta->version.data(), 88, selected); + } else { + ui::DrawUtf8Line(canvas_, 20, y + 4, label, 360, selected); + } } } } else if (scene == MicroAppScene::Loading) { + const auto& meta = apps.current_metadata(); + if (meta.icon_side) { + ui::DrawMicroAppIcon(canvas_, meta, 184, 80, 32); + ui::DrawUtf8Line(canvas_, 20, 164, meta.author.data(), 360); + ui::DrawUtf8Line(canvas_, 20, 190, meta.version.data(), 360); + } canvas_.TextCentered(122, Tr(Text::AppLoading)); } else if (scene == MicroAppScene::Running) { ui::DrawMicroAppFrame(canvas_, apps.engine().frame()); } else { Text reason = Text::AppFailed; - if (apps.storage_result() != ESP_OK) reason = Text::AppReadFailed; + if (apps.storage_result() == ESP_ERR_INVALID_ARG || apps.storage_result() == ESP_ERR_INVALID_SIZE) + reason = Text::AppInvalidSource; + else if (apps.storage_result() != ESP_OK) reason = Text::AppReadFailed; else if (apps.engine().error() == runtime::Error::Instructions) reason = Text::AppInstructionLimit; else if (apps.engine().error() == runtime::Error::Memory) reason = Text::AppMemoryLimit; else if (apps.engine().error() == runtime::Error::InvalidSource) reason = Text::AppInvalidSource; + else if (apps.engine().error() == runtime::Error::Permission) reason = Text::AppPermissionDenied; canvas_.TextCentered(100, Tr(reason)); canvas_.TextCentered(158, Tr(Text::AppTryAnother)); } diff --git a/components/zectrix_demo_ui/zectrix_micro_app_view.cc b/components/zectrix_demo_ui/zectrix_micro_app_view.cc index f98708a..b01ac54 100644 --- a/components/zectrix_demo_ui/zectrix_micro_app_view.cc +++ b/components/zectrix_demo_ui/zectrix_micro_app_view.cc @@ -4,6 +4,21 @@ namespace zectrix::ui { +void DrawMicroAppIcon(ZectrixCanvas& canvas, const package::Metadata& meta, + int x, int y, int side, bool inverse) { + if ((meta.icon_side != 16 && meta.icon_side != 32) || (side != 16 && side != 32)) return; + const int step = std::max(1, meta.icon_side / side); + for (int row = 0; row < side; ++row) for (int col = 0; col < side; ++col) { + bool ink = false; + // Keep single-pixel strokes visible when reducing a 32-pixel icon. + for (int dy = 0; dy < step; ++dy) for (int dx = 0; dx < step; ++dx) { + const int bit = (row * meta.icon_side / side + dy) * meta.icon_side + col * meta.icon_side / side + dx; + ink |= (meta.icon[bit / 8] & (0x80 >> (bit % 8))) != 0; + } + canvas.Pixel(x + col, y + row, ink != inverse); + } +} + void DrawMicroAppFrame(ZectrixCanvas& canvas, const runtime::Frame& frame) { const auto clip = canvas.clip(); constexpr int x = 12, y = 66; diff --git a/components/zectrix_demo_ui/zectrix_sleep_ui.cc b/components/zectrix_demo_ui/zectrix_sleep_ui.cc index ac5ab56..347ad90 100644 --- a/components/zectrix_demo_ui/zectrix_sleep_ui.cc +++ b/components/zectrix_demo_ui/zectrix_sleep_ui.cc @@ -96,29 +96,29 @@ void DrawReading(ZectrixCanvas& canvas, const SleepCoverSnapshot& snapshot) { esp_err_t ZectrixDemoUi::ShowSleepCoverMenu(SleepCoverStyle selected, SleepCoverStyle active, const char* status, bool full_refresh) { DrawFrame(Tr(Text::SleepCover), Tr(Text::NavCoverPreview)); - const char* styles[] = {Tr(Text::DailyDashboard), Tr(Text::QuietLandscape), Tr(Text::BlankPrivacy)}; + const char* styles[] = {Tr(Text::DailyDashboard), Tr(Text::QuietLandscape), Tr(Text::BlankPrivacy), Tr(Text::PhonePicture)}; const char* details[] = { #if CONFIG_ZECTRIX_ENABLE_READER Tr(Text::DashboardDetail), #else Tr(Text::CalendarDetail), #endif - Tr(Text::LandscapeDetail), Tr(Text::BlankDetail)}; - for (unsigned i = 0; i < std::size(styles); ++i) { + Tr(Text::LandscapeDetail), Tr(Text::BlankDetail), Tr(Text::PhonePictureDetail)}; + for (unsigned i = 0; i < kSleepCoverStyleCount; ++i) { const bool chosen = i == static_cast(selected); - const int y = 54 + i * 62; - canvas_.FillRect(16, y, 368, 30, chosen); - canvas_.Rect(16, y, 368, 30); - canvas_.Text(28, y + 7, styles[i], 1, chosen); - if (i == static_cast(active)) canvas_.Text(354, y + 7, "*", 1, chosen); - canvas_.Text(16, y + 34, details[i]); + const int y = kSleepCoverStyleCount == 4 ? 54 + i * 47 : 54 + i * 62; + canvas_.FillRect(16, y, 368, 26, chosen); + canvas_.Rect(16, y, 368, 26); + canvas_.Text(28, y + 5, styles[i], 1, chosen); + if (i == static_cast(active)) canvas_.Text(354, y + 5, "*", 1, chosen); + canvas_.Text(16, y + 28, details[i]); } canvas_.Text(16, 246, status ? status : Tr(Text::SetCoverHint)); return full_refresh ? RefreshFull() : RefreshAuto(); } esp_err_t ZectrixDemoUi::ShowSleepCover(const SleepCoverSnapshot& snapshot, SleepCoverStyle style, - bool preview, bool preference_saved) { + bool preview, bool preference_saved, const SleepCoverImage* picture) { if (display_ == nullptr) return ESP_ERR_INVALID_STATE; style = SleepCoverSetting(static_cast(style)); if (!preview && style == SleepCoverStyle::Blank) return ClearDisplay(); @@ -137,6 +137,20 @@ esp_err_t ZectrixDemoUi::ShowSleepCover(const SleepCoverSnapshot& snapshot, Slee } const auto calendar = CalendarForSleep(snapshot.clock); const auto& quote = QuoteForSleep(calendar); + bool picture_loaded = false; + if (style == SleepCoverStyle::Picture && picture && picture->read) { + std::array row{}; + picture_loaded = true; + for (int y = preview ? 24 : 0; y < 273; ++y) { + if (!picture->read(picture->context, y * row.size(), row.data(), row.size())) { + picture_loaded = false; + break; + } + // PBM uses one for black; the display canvas uses one for white. + for (std::size_t x = 0; x < row.size(); ++x) canvas_.data()[y * row.size() + x] = ~row[x]; + } + if (!picture_loaded) return ESP_FAIL; + } if (style == SleepCoverStyle::Dashboard) { DrawCalendar(canvas_, snapshot, calendar); #if CONFIG_ZECTRIX_ENABLE_READER @@ -144,7 +158,7 @@ esp_err_t ZectrixDemoUi::ShowSleepCover(const SleepCoverSnapshot& snapshot, Slee #endif char line[96]; std::snprintf(line, sizeof(line), "%s %s", Tr(quote.first_text, quote.first), Tr(quote.second_text, quote.second)); - zectrix::ui::DrawUtf8Line(canvas_, 16, 246, line, 368); + zectrix::ui::DrawUtf8Line(canvas_, 16, 246, snapshot.weather_line[0] ? snapshot.weather_line.data() : line, 368); } else if (style == SleepCoverStyle::Quote) { canvas_.TextCentered(31, Tr(Text::BetweenPages)); DrawLandscape(canvas_, calendar.day_number); @@ -156,6 +170,12 @@ esp_err_t ZectrixDemoUi::ShowSleepCover(const SleepCoverSnapshot& snapshot, Slee value.year, value.month, value.day, value.hour, value.minute); else std::snprintf(date, sizeof(date), "%s", Tr(Text::TimeNotSet)); canvas_.TextCentered(246, date); + } else if (style == SleepCoverStyle::Picture) { + if (!picture_loaded) { + canvas_.TextCentered(92, Tr(Text::PhonePicture)); + canvas_.TextCentered(133, Tr(Text::PhonePictureMissing)); + canvas_.TextCentered(174, Tr(Text::PhonePictureDetail)); + } } else { canvas_.TextCentered(101, Tr(Text::QuietBlankScreen)); canvas_.TextCentered(152, Tr(Text::ReadingStaysPrivate)); diff --git a/components/zectrix_runtime/include/zectrix_runtime.h b/components/zectrix_runtime/include/zectrix_runtime.h index 679a805..969d8e9 100644 --- a/components/zectrix_runtime/include/zectrix_runtime.h +++ b/components/zectrix_runtime/include/zectrix_runtime.h @@ -17,7 +17,7 @@ inline constexpr int kInstructionLimit = 10000; inline constexpr int kWidth = 376, kHeight = 192; enum class Key : uint8_t { Up = 1, Down, Ok }; -enum class Error : uint8_t { None, InvalidSource, Memory, Instructions, Guest, Drawing }; +enum class Error : uint8_t { None, InvalidSource, Memory, Instructions, Guest, Drawing, Permission }; enum class DrawKind : uint8_t { Text, Rect, Fill }; struct DrawCommand { @@ -36,6 +36,11 @@ struct Frame { struct HeapStats { std::size_t live = 0, peak = 0, rejected = 0; }; +struct Options { + int instructions = kInstructionLimit; + bool display = true, input = true; +}; + // One foreground owner calls this object serially. No tasks or hardware calls. class Engine final { public: @@ -44,7 +49,7 @@ class Engine final { Engine(const Engine&) = delete; Engine& operator=(const Engine&) = delete; - bool Start(const uint8_t* source, std::size_t size); + bool Start(const uint8_t* source, std::size_t size, Options options = {}); bool Handle(Key key); bool Draw(); void Stop(); @@ -55,6 +60,7 @@ class Engine final { Error error() const { return error_; } const char* detail() const { return detail_.data(); } HeapStats heap() const { return heap_; } + int instruction_limit() const { return options_.instructions; } private: enum class Phase : uint8_t { Idle, Initialize, Event, Render }; @@ -79,6 +85,7 @@ class Engine final { lua_State* callback_ = nullptr; std::size_t heap_limit_; HeapStats heap_{}; + Options options_{}; Frame frame_{}; std::array detail_{}; Error error_ = Error::None; diff --git a/components/zectrix_runtime/zectrix_runtime.cc b/components/zectrix_runtime/zectrix_runtime.cc index c09c285..8c93af3 100644 --- a/components/zectrix_runtime/zectrix_runtime.cc +++ b/components/zectrix_runtime/zectrix_runtime.cc @@ -129,11 +129,14 @@ int Engine::Finish(lua_State* state, int, std::intptr_t) { return 0; } -bool Engine::Start(const uint8_t* source, std::size_t size) { +bool Engine::Start(const uint8_t* source, std::size_t size, Options options) { Stop(); heap_ = {}; error_ = Error::None; detail_[0] = 0; + options_ = options; + if (options.instructions < 100 || options.instructions > kInstructionLimit) + return Fail(Error::InvalidSource, "unsupported instruction quota"); if (!source || !size || size > kSourceLimit) return Fail(Error::InvalidSource, "invalid script size"); owner_ = lua_newstate(Allocate, this); if (!owner_) return Fail(Error::Memory, "script memory limit"); @@ -146,7 +149,7 @@ bool Engine::Start(const uint8_t* source, std::size_t size) { // The loader is protected internally. Binary Lua chunks are never accepted. const int loaded = luaL_loadbufferx(callback_, reinterpret_cast(source), size, "app", "t"); if (!Result(loaded)) return false; - lua_sethook(callback_, Budget, LUA_MASKCOUNT, kInstructionLimit); + lua_sethook(callback_, Budget, LUA_MASKCOUNT, options_.instructions); int results = 0; if (!Result(lua_resume(callback_, owner_, 0, &results))) return false; phase_ = Phase::Idle; @@ -162,20 +165,25 @@ bool Engine::Run(Phase phase) { if (!Result(lua_resetthread(callback_))) return false; lua_settop(callback_, 0); lua_pushcfunction(callback_, Invoke); - lua_sethook(callback_, Budget, LUA_MASKCOUNT, kInstructionLimit); + lua_sethook(callback_, Budget, LUA_MASKCOUNT, options_.instructions); int results = 0; if (!Result(lua_resume(callback_, owner_, 0, &results))) return false; phase_ = Phase::Idle; return true; } -bool Engine::Handle(Key key) { key_ = key; redraw_ = false; return Run(Phase::Event); } +bool Engine::Handle(Key key) { + key_ = key; + redraw_ = false; + return options_.input ? Run(Phase::Event) : active(); +} bool Engine::Draw() { frame_.count = frame_.text_size = 0; return Run(Phase::Render); } bool Engine::Result(int status) { if (status == LUA_OK) return true; const auto error = status == LUA_YIELD ? Error::Instructions : status == LUA_ERRMEM ? Error::Memory : - status == LUA_ERRSYNTAX ? Error::InvalidSource : error_ == Error::Drawing ? Error::Drawing : Error::Guest; + status == LUA_ERRSYNTAX ? Error::InvalidSource : + error_ == Error::Drawing || error_ == Error::Permission ? error_ : Error::Guest; // Error objects can be arbitrary values. Only copy an existing Lua string. const char* message = callback_ && lua_type(callback_, -1) == LUA_TSTRING ? lua_tostring(callback_, -1) : nullptr; return Fail(error, status == LUA_YIELD ? "script instruction limit" : message ? message : "script failed"); @@ -208,6 +216,10 @@ int Engine::Coordinate(lua_State* state, int index, int upper) { int Engine::Text(lua_State* state) { auto& self = Self(state); + if (!self.options_.display) { + self.error_ = Error::Permission; + return luaL_error(state, "display permission not declared"); + } if (self.phase_ != Phase::Render) return luaL_error(state, "drawing outside on_render"); const auto x = Coordinate(state, 1, kWidth - 1), y = Coordinate(state, 2, kHeight - 1); std::size_t size = 0; @@ -230,6 +242,10 @@ int Engine::Text(lua_State* state) { int Engine::Rectangle(lua_State* state, DrawKind kind) { auto& self = Self(state); + if (!self.options_.display) { + self.error_ = Error::Permission; + return luaL_error(state, "display permission not declared"); + } if (self.phase_ != Phase::Render) return luaL_error(state, "drawing outside on_render"); const auto x = Coordinate(state, 1, kWidth - 1), y = Coordinate(state, 2, kHeight - 1); const auto width = Coordinate(state, 3, kWidth - x), height = Coordinate(state, 4, kHeight - y); diff --git a/components/zectrix_storage/include/zectrix_app_package.h b/components/zectrix_storage/include/zectrix_app_package.h new file mode 100644 index 0000000..4e639ac --- /dev/null +++ b/components/zectrix_storage/include/zectrix_app_package.h @@ -0,0 +1,131 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace zectrix::package { + +inline constexpr uint16_t kFormatVersion = 1, kGuestApi = 1; +inline constexpr uint16_t kDisplay = 1, kInput = 2; +inline constexpr uint32_t kSourceLimit = 32768, kInstructionLimit = 10000; +inline constexpr std::size_t kHeaderSize = 160, kIconLimit = 128; +inline constexpr uint32_t kPackageLimit = kHeaderSize + kIconLimit + kSourceLimit; + +struct Metadata { + std::array name{}; + std::array version{}; + std::array author{}; + std::array icon{}; + uint32_t source_size = 0, instructions = 0; + uint16_t permissions = 0; + uint8_t icon_side = 0; + std::size_t IconBytes() const { return icon_side * icon_side / 8; } + uint32_t SourceOffset() const { return kHeaderSize + IconBytes(); } +}; + +// UTF-8 is validated across transfer boundaries without retaining the source. +class TextValidator { +public: + bool Feed(const uint8_t* data, std::size_t size, bool multiline = true) { + if (!valid_ || (size && !data)) return valid_ = false; + for (std::size_t i = 0; i < size; ++i) { + const uint8_t c = data[i]; + if (remaining_) { + if ((c & 0xc0) != 0x80) return valid_ = false; + codepoint_ = (codepoint_ << 6) | (c & 63); + if (!--remaining_ && (codepoint_ < minimum_ || codepoint_ > 0x10ffff || + (codepoint_ >= 0xd800 && codepoint_ <= 0xdfff))) return valid_ = false; + } else if (c < 0x80) { + if ((c < 0x20 && !(multiline && (c == '\t' || c == '\n' || c == '\r'))) || c == 0x7f) + return valid_ = false; + } else { + remaining_ = c >= 0xc2 && c <= 0xdf ? 1 : c >= 0xe0 && c <= 0xef ? 2 : + c >= 0xf0 && c <= 0xf4 ? 3 : 0; + if (!remaining_) return valid_ = false; + minimum_ = remaining_ == 1 ? 0x80 : remaining_ == 2 ? 0x800 : 0x10000; + codepoint_ = c & (remaining_ == 1 ? 31 : remaining_ == 2 ? 15 : 7); + } + } + return true; + } + bool Complete() const { return valid_ && !remaining_; } +private: + uint32_t codepoint_ = 0, minimum_ = 0; + unsigned remaining_ = 0; + bool valid_ = true; +}; + +inline uint32_t Read32(const uint8_t* p) { + return uint32_t{p[0]} | (uint32_t{p[1]} << 8) | (uint32_t{p[2]} << 16) | (uint32_t{p[3]} << 24); +} +inline uint16_t Read16(const uint8_t* p) { return p[0] | (uint16_t{p[1]} << 8); } + +template +bool ReadText(const uint8_t* data, std::array& output, bool ascii = false) { + std::size_t size = 0; + while (size < N && data[size]) ++size; + if (!size || size == N) return false; + for (std::size_t i = size; i < N; ++i) if (data[i]) return false; + if (ascii) for (std::size_t i = 0; i < size; ++i) if (data[i] < 0x21 || data[i] > 0x7e) return false; + TextValidator text; + if (!text.Feed(data, size, false) || !text.Complete()) return false; + std::memcpy(output.data(), data, N); + return true; +} + +// Parse fixed-width fields explicitly; the wire layout never depends on C++ padding. +inline bool ReadHeader(const uint8_t* data, std::size_t size, uint32_t file_size, Metadata& output) { + if (!data || size != kHeaderSize || std::memcmp(data, "ZAPP", 4) || + Read16(data + 4) != kFormatVersion || Read16(data + 6) != kGuestApi || + data[8] != 1 || (data[9] != 16 && data[9] != 32) || Read32(data + 20)) return false; + Metadata meta; + meta.icon_side = data[9]; + meta.permissions = Read16(data + 10); + meta.instructions = Read32(data + 12); + meta.source_size = Read32(data + 16); + if ((meta.permissions & ~(kDisplay | kInput)) || meta.instructions < 100 || + meta.instructions > kInstructionLimit || !meta.source_size || meta.source_size > kSourceLimit || + file_size != meta.SourceOffset() + meta.source_size || + !ReadText(data + 24, meta.name) || !ReadText(data + 88, meta.version, true) || + !ReadText(data + 112, meta.author)) return false; + output = meta; + return true; +} + +class Validator { +public: + void Reset(uint32_t size) { *this = {}; expected_ = size; } + bool Feed(const void* input, std::size_t size) { + if (!valid_ || (size && !input) || size > expected_ - received_) return valid_ = false; + const auto* data = static_cast(input); + while (size) { + const auto end = received_ < kHeaderSize ? kHeaderSize : received_ < source_offset_ ? source_offset_ : expected_; + const auto chunk = std::min(size, end - received_); + if (received_ < kHeaderSize) { + std::memcpy(header_.data() + received_, data, chunk); + if (received_ + chunk == kHeaderSize) { + Metadata meta; + if (!ReadHeader(header_.data(), header_.size(), expected_, meta)) return valid_ = false; + source_offset_ = meta.SourceOffset(); + } + } else if (received_ >= source_offset_ && !source_.Feed(data, chunk)) return valid_ = false; + data += chunk; + size -= chunk; + received_ += chunk; + } + return true; + } + bool Complete() const { + return valid_ && source_offset_ && received_ == expected_ && source_.Complete(); + } +private: + std::array header_{}; + TextValidator source_; + uint32_t expected_ = 0, received_ = 0, source_offset_ = 0; + bool valid_ = true; +}; + +} // namespace zectrix::package diff --git a/components/zectrix_storage/include/zectrix_app_storage.h b/components/zectrix_storage/include/zectrix_app_storage.h index 716c70a..2ceac6c 100644 --- a/components/zectrix_storage/include/zectrix_app_storage.h +++ b/components/zectrix_storage/include/zectrix_app_storage.h @@ -9,19 +9,28 @@ namespace zectrix::storage { class AppStorage final { public: static constexpr std::size_t kNameSize = 48; - static constexpr uint32_t kSourceLimit = 32768; + static constexpr uint32_t kSourceLimit = package::kSourceLimit; explicit AppStorage(BookStorage& storage) : storage_(storage) {} static bool ValidName(const char* name) { return BookStorage::ValidAppName(name); } + static bool Packaged(const char* name) { + if (!name) return false; + const char* suffix = std::strrchr(name, '.'); + if (!suffix || std::strlen(suffix) != 5) return false; + const char extension[] = ".zapp"; + for (unsigned i = 0; i < 5; ++i) if ((suffix[i] | 0x20) != extension[i]) return false; + return true; + } + static uint32_t SizeLimit(const char* name) { return Packaged(name) ? package::kPackageLimit : kSourceLimit; } esp_err_t List(BookEntry* entries, std::size_t capacity, std::size_t* count, bool* more, const char* cursor = nullptr, bool previous = false) { return storage_.ListImpl(entries, capacity, count, more, cursor, true, previous); } - esp_err_t Open(const char* name, BookFile* file) { return storage_.OpenImpl(name, file, false, true); } - esp_err_t OpenManaged(const char* name, BookFile* file) { return storage_.OpenImpl(name, file, true, true); } + esp_err_t Open(const char* name, BookFile* file) { return storage_.OpenImpl(name, file, false, BookStorage::Content::App); } + esp_err_t OpenManaged(const char* name, BookFile* file) { return storage_.OpenImpl(name, file, true, BookStorage::Content::App); } BookWriteResult BeginUpload(const char* name, uint32_t size, BookUpload* upload) { - return storage_.UploadImpl(name, size, upload, true); + return storage_.UploadImpl(name, size, upload, BookStorage::Content::App); } - BookWriteResult Remove(const char* name) { return storage_.RemoveImpl(name, true); } + BookWriteResult Remove(const char* name) { return storage_.RemoveImpl(name, BookStorage::Content::App); } private: BookStorage& storage_; diff --git a/components/zectrix_storage/include/zectrix_book_storage.h b/components/zectrix_storage/include/zectrix_book_storage.h index 3273b5b..91cfdaf 100644 --- a/components/zectrix_storage/include/zectrix_book_storage.h +++ b/components/zectrix_storage/include/zectrix_book_storage.h @@ -7,6 +7,7 @@ #include #include "esp_err.h" +#include "zectrix_app_package.h" namespace zectrix::storage { @@ -42,6 +43,8 @@ class BookUpload { std::array target_{}, temporary_{}; uint32_t expected_ = 0, received_ = 0; BookWriteResult error_ = BookWriteResult::Ok; + package::Validator package_; + bool packaged_ = false, cover_ = false; }; class BookFile { @@ -81,6 +84,9 @@ class BookStorage { esp_err_t OpenManaged(const char* name, BookFile* file); BookWriteResult BeginUpload(const char* name, uint32_t size, BookUpload* upload); BookWriteResult Remove(const char* name); + esp_err_t OpenCover(BookFile* file, bool managed = false); + BookWriteResult BeginCoverUpload(uint32_t size, BookUpload* upload); + BookWriteResult RemoveCover(); // Explicit maintenance only. Readers, uploads and management must exit first. esp_err_t Wipe(); @@ -88,16 +94,17 @@ class BookStorage { friend class BookFile; friend class BookUpload; friend class AppStorage; + enum class Content : uint8_t { Book, App, Cover }; void ReaderClosed(); static bool ValidAppName(const char* name); esp_err_t ListImpl(BookEntry* entries, std::size_t capacity, std::size_t* count, bool* truncated, const char* after, bool application, bool reverse = false); - esp_err_t OpenImpl(const char* name, BookFile* file, bool managed, bool application = false); - BookWriteResult UploadImpl(const char* name, uint32_t size, BookUpload* upload, bool application); - BookWriteResult RemoveImpl(const char* name, bool application); + esp_err_t OpenImpl(const char* name, BookFile* file, bool managed, Content kind = Content::Book); + BookWriteResult UploadImpl(const char* name, uint32_t size, BookUpload* upload, Content kind); + BookWriteResult RemoveImpl(const char* name, Content kind); esp_err_t ReadSpace(BookSpace* space); esp_err_t Mount(); - bool Path(const char* name, char* output, std::size_t capacity, bool application = false) const; + bool Path(const char* name, char* output, std::size_t capacity, Content kind = Content::Book) const; std::array root_{}; bool mounted_ = false; bool managing_ = false, uploading_ = false; diff --git a/components/zectrix_storage/include/zectrix_cover_image.h b/components/zectrix_storage/include/zectrix_cover_image.h new file mode 100644 index 0000000..373b4f8 --- /dev/null +++ b/components/zectrix_storage/include/zectrix_cover_image.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace zectrix::storage::cover { + +// Phone-side conversion keeps decoding and large image buffers off the device. +inline constexpr char kHeader[] = "P4\n400 300\n"; +inline constexpr char kName[] = "phone.pbm"; +constexpr std::size_t kHeaderSize = sizeof(kHeader) - 1; +constexpr uint32_t kPixelBytes = 400 * 300 / 8; +constexpr uint32_t kFileSize = kHeaderSize + kPixelBytes; + +} // namespace zectrix::storage::cover diff --git a/components/zectrix_storage/include/zectrix_storage_service.h b/components/zectrix_storage/include/zectrix_storage_service.h index 54b94f8..f881303 100644 --- a/components/zectrix_storage/include/zectrix_storage_service.h +++ b/components/zectrix_storage/include/zectrix_storage_service.h @@ -39,6 +39,7 @@ class StorageService { esp_err_t ListBooks(BookEntry* entries, std::size_t capacity, std::size_t* count, bool* truncated); esp_err_t OpenBook(const char* name, BookFile* file); + esp_err_t OpenCover(BookFile* file); esp_err_t BeginBookManagement(BookStorage** books); esp_err_t ListApps(BookEntry* entries, std::size_t capacity, std::size_t* count, bool* more, const char* cursor = nullptr, bool previous = false); diff --git a/components/zectrix_storage/zectrix_book_storage.cc b/components/zectrix_storage/zectrix_book_storage.cc index 7fe448c..ed15741 100644 --- a/components/zectrix_storage/zectrix_book_storage.cc +++ b/components/zectrix_storage/zectrix_book_storage.cc @@ -1,5 +1,6 @@ #include "zectrix_book_storage.h" #include "zectrix_app_storage.h" +#include "zectrix_cover_image.h" #include #include @@ -73,7 +74,8 @@ bool BookStorage::ValidName(const char* name) { return BookName(name); } bool BookStorage::ValidAppName(const char* name) { if (!ObjectName(name, AppStorage::kNameSize)) return false; const char* suffix = std::strrchr(name, '.'); - return suffix && suffix != name && strcasecmp(suffix, ".lua") == 0; + return suffix && suffix != name && + (strcasecmp(suffix, ".lua") == 0 || strcasecmp(suffix, ".zapp") == 0); } bool BookFile::Read(uint32_t offset, void* output, std::size_t size) { @@ -155,9 +157,12 @@ esp_err_t BookStorage::Mount() { return ESP_OK; } -bool BookStorage::Path(const char* name, char* output, std::size_t capacity, bool application) const { - if (!(application ? ValidAppName(name) : BookName(name))) return false; - const int size = std::snprintf(output, capacity, "%s/%s%s", root_.data(), application ? kAppPrefix : "", name); +bool BookStorage::Path(const char* name, char* output, std::size_t capacity, Content kind) const { + const bool application = kind == Content::App; + if (kind == Content::Cover ? (!name || std::strcmp(name, cover::kName) != 0) : + !(application ? ValidAppName(name) : BookName(name))) return false; + const int size = std::snprintf(output, capacity, "%s/%s%s", root_.data(), + application ? kAppPrefix : kind == Content::Cover ? ".cover-" : "", name); return size > 0 && static_cast(size) < capacity; } @@ -190,7 +195,7 @@ esp_err_t BookStorage::ListImpl(BookEntry* entries, std::size_t capacity, if (after && (reverse ? std::strcmp(name, after) >= 0 : std::strcmp(name, after) <= 0)) continue; char path[256]; struct stat info{}; - if (!Path(name, path, sizeof(path), application) || !FileInfo(path, &info) || + if (!Path(name, path, sizeof(path), application ? Content::App : Content::Book) || !FileInfo(path, &info) || !S_ISREG(info.st_mode) || info.st_size < 0 || static_cast(info.st_size) > LONG_MAX || static_cast(info.st_size) > UINT32_MAX) continue; @@ -220,13 +225,18 @@ esp_err_t BookStorage::OpenManaged(const char* name, BookFile* file) { return OpenImpl(name, file, true); } -esp_err_t BookStorage::OpenImpl(const char* name, BookFile* file, bool managed, bool application) { +esp_err_t BookStorage::OpenCover(BookFile* file, bool managed) { + return OpenImpl(cover::kName, file, managed, Content::Cover); +} + +esp_err_t BookStorage::OpenImpl(const char* name, BookFile* file, bool managed, Content kind) { + const bool application = kind == Content::App; if (!file) return ESP_ERR_INVALID_ARG; file->Close(); std::lock_guard lock(mutex_); if (managing_ != managed || uploading_) return ESP_ERR_INVALID_STATE; char path[256]; - if (!Path(name, path, sizeof(path), application)) return ESP_ERR_INVALID_ARG; + if (!Path(name, path, sizeof(path), kind)) return ESP_ERR_INVALID_ARG; const auto mounted = Mount(); if (mounted != ESP_OK) return mounted; struct stat info{}; @@ -234,10 +244,18 @@ esp_err_t BookStorage::OpenImpl(const char* name, BookFile* file, bool managed, if (!S_ISREG(info.st_mode) || info.st_size < 0 || static_cast(info.st_size) > LONG_MAX || static_cast(info.st_size) > UINT32_MAX || - (application && (info.st_size == 0 || static_cast(info.st_size) > AppStorage::kSourceLimit))) + (application && (info.st_size == 0 || static_cast(info.st_size) > AppStorage::SizeLimit(name)))) return ESP_ERR_INVALID_SIZE; auto* opened = std::fopen(path, "rb"); if (!opened) return ESP_FAIL; + if (kind == Content::Cover) { + char header[cover::kHeaderSize]; + if (info.st_size != cover::kFileSize || std::fread(header, 1, sizeof(header), opened) != sizeof(header) || + std::memcmp(header, cover::kHeader, sizeof(header)) != 0 || std::fseek(opened, 0, SEEK_SET) != 0) { + std::fclose(opened); + return ESP_ERR_INVALID_SIZE; + } + } file->file_ = opened; file->size_ = static_cast(info.st_size); file->owner_ = this; @@ -297,15 +315,24 @@ esp_err_t BookStorage::Space(BookSpace* space) { } BookWriteResult BookStorage::BeginUpload(const char* name, uint32_t size, BookUpload* upload) { - return UploadImpl(name, size, upload, false); + return UploadImpl(name, size, upload, Content::Book); +} + +BookWriteResult BookStorage::BeginCoverUpload(uint32_t size, BookUpload* upload) { + return UploadImpl(cover::kName, size, upload, Content::Cover); } -BookWriteResult BookStorage::UploadImpl(const char* name, uint32_t size, BookUpload* upload, bool application) { +BookWriteResult BookStorage::UploadImpl(const char* name, uint32_t size, BookUpload* upload, Content kind) { + const bool application = kind == Content::App; + if (kind == Content::Cover && size != cover::kFileSize) return BookWriteResult::Invalid; if (!upload || upload->owner_) return BookWriteResult::Busy; std::lock_guard lock(mutex_); if (!managing_ || uploading_ || readers_) return BookWriteResult::Busy; - if ((application && (!size || size > AppStorage::kSourceLimit)) || - !Path(name, upload->target_.data(), upload->target_.size(), application)) return BookWriteResult::Invalid; + if ((application && (!size || size > AppStorage::SizeLimit(name))) || + !Path(name, upload->target_.data(), upload->target_.size(), kind)) return BookWriteResult::Invalid; + upload->cover_ = kind == Content::Cover; + upload->packaged_ = application && AppStorage::Packaged(name); + if (upload->packaged_ && size < package::kHeaderSize + 32 + 1) return BookWriteResult::Invalid; struct stat info{}; if (FileInfo(upload->target_.data(), &info)) return BookWriteResult::Exists; if (errno != ENOENT) return BookWriteResult::IoError; @@ -318,20 +345,23 @@ BookWriteResult BookStorage::UploadImpl(const char* name, uint32_t size, BookUpl upload->expected_ = size; upload->received_ = 0; upload->error_ = BookWriteResult::Ok; + upload->package_.Reset(size); upload->owner_ = this; uploading_ = true; return BookWriteResult::Ok; } BookWriteResult BookStorage::Remove(const char* name) { - return RemoveImpl(name, false); + return RemoveImpl(name, Content::Book); } -BookWriteResult BookStorage::RemoveImpl(const char* name, bool application) { +BookWriteResult BookStorage::RemoveCover() { return RemoveImpl(cover::kName, Content::Cover); } + +BookWriteResult BookStorage::RemoveImpl(const char* name, Content kind) { std::lock_guard lock(mutex_); if (!managing_ || uploading_ || readers_) return BookWriteResult::Busy; char path[256]; - if (!Path(name, path, sizeof(path), application)) return BookWriteResult::Invalid; + if (!Path(name, path, sizeof(path), kind)) return BookWriteResult::Invalid; struct stat info{}; if (!FileInfo(path, &info)) return errno == ENOENT ? BookWriteResult::NotFound : BookWriteResult::IoError; if (!S_ISREG(info.st_mode)) return BookWriteResult::Invalid; @@ -341,6 +371,10 @@ BookWriteResult BookStorage::RemoveImpl(const char* name, bool application) { BookWriteResult BookUpload::Write(const void* data, std::size_t size) { if (error_ != BookWriteResult::Ok) return error_; if (!file_ || (size && !data) || size > expected_ - received_) return BookWriteResult::Invalid; + if (cover_ && received_ < cover::kHeaderSize && size && + std::memcmp(data, cover::kHeader + received_, std::min(size, cover::kHeaderSize - received_)) != 0) + return error_ = BookWriteResult::Invalid; + if (packaged_ && !package_.Feed(data, size)) return error_ = BookWriteResult::Invalid; // A short write may already have advanced the file. Replaying that chunk // on this handle could publish duplicate or truncated data. if (size && std::fwrite(data, 1, size, file_) != size) @@ -352,6 +386,7 @@ BookWriteResult BookUpload::Write(const void* data, std::size_t size) { BookWriteResult BookUpload::Commit() { if (error_ != BookWriteResult::Ok) return error_; if (!file_ || received_ != expected_) return BookWriteResult::Invalid; + if (packaged_ && !package_.Complete()) return error_ = BookWriteResult::Invalid; if (std::fflush(file_) != 0 || fsync(fileno(file_)) != 0) return error_ = errno == ENOSPC ? BookWriteResult::NoSpace : BookWriteResult::IoError; const int closed = std::fclose(file_); diff --git a/components/zectrix_storage/zectrix_storage_service.cc b/components/zectrix_storage/zectrix_storage_service.cc index 5cfc72a..3093e7e 100644 --- a/components/zectrix_storage/zectrix_storage_service.cc +++ b/components/zectrix_storage/zectrix_storage_service.cc @@ -186,6 +186,11 @@ esp_err_t StorageService::OpenBook(const char* name, BookFile* file) { return result == ESP_OK ? impl_->books->Open(name, file) : result; } +esp_err_t StorageService::OpenCover(BookFile* file) { + const auto result = InitializeBooks(); + return result == ESP_OK ? impl_->books->OpenCover(file) : result; +} + esp_err_t StorageService::BeginBookManagement(BookStorage** books) { if (!books) return ESP_ERR_INVALID_ARG; *books = nullptr; diff --git a/docs/BOOK_TRANSFER.md b/docs/BOOK_TRANSFER.md index b87f4b7..120ed01 100644 --- a/docs/BOOK_TRANSFER.md +++ b/docs/BOOK_TRANSFER.md @@ -4,6 +4,12 @@ L1.3 adds **SEND BOOKS** to the Launcher. A phone or computer can upload, download and delete TXT/EPUB files through a local web page. No companion app or internet connection is required. +E2.3 extends the same session to [`.zapp` packages and Lua apps](ZAPP_PACKAGES.md) +when the runtime is enabled. The page discovers support from `apps_supported`, +accepts a mixed book/app batch and lists both namespaces. Installed apps open +from **Home > Apps** after leaving Send Books; OK on the completed transfer +screen retains its existing Book Reader action. + ## Send books 1. Initialize the content partition once with the explicit `books-flash` @@ -63,6 +69,7 @@ case. | File request | 120-second deadline, with a 1-second socket read/write timeout. | | Successful batch | The browser finishes the session immediately. Firmware allows 500 ms for the final reply before shutdown. | | Filename | Valid UTF-8, at most 63 bytes, `.txt` or `.epub`, without separators or control characters. | +| App filename/size | At most 47 UTF-8 bytes, `.zapp` or `.lua`; packages at most 33,056 bytes, raw sources 1–32,768 bytes. | | Capacity | Upload admission leaves 25 percent of the reported SPIFFS capacity for metadata and garbage collection. The page shows the remaining upload allowance. | | Library | The web page loads 32 entries per request and can manage all eligible files. The reader shows the first 32 names in byte order. | @@ -118,12 +125,20 @@ trusted home network. Station mode does not encrypt HTTP traffic. | `PUT /api/books/` | Upload raw bytes with `Content-Length`. | | `GET /api/books/` | Download bytes as an attachment. | | `DELETE /api/books/` | Delete one book, with an empty body. | +| `GET /api/apps` | List apps and shared capacity; same pagination, plus `?after=`. | +| `PUT /api/apps/` | Install a package or Lua source with exact `Content-Length`. | +| `GET /api/apps/` | Export the complete installed file. | +| `DELETE /api/apps/` | Remove one app, with an empty body. | | `POST /api/finish` | End the session, with an empty body. | Names use UTF-8 percent encoding. Responses distinguish unauthorized (`401`), invalid filename (`400`), missing file (`404`), existing name or busy (`409`), interrupted/timed-out upload (`408`), oversized request (`413`), ended session (`503`) and insufficient storage (`507`). File operations are serialized. +Both list responses include `apps_supported`. Disabled app operations return +`503`; invalid package structure or compatibility returns `400`. App uploads +validate incrementally before publication and never execute code. The same +authorization, cancellation, storage lease and radio shutdown apply to apps. ## Development and verification diff --git a/docs/CONNECTIVITY_CONTRACT.md b/docs/CONNECTIVITY_CONTRACT.md index b44d22c..28fe734 100644 --- a/docs/CONNECTIVITY_CONTRACT.md +++ b/docs/CONNECTIVITY_CONTRACT.md @@ -150,6 +150,26 @@ Android decodes this payload for its progress card and assigns outgoing revision from its existing durable queue when the user selects Resume this position. See [READER.md](READER.md) for file identity, coalescing, limits and installation. +### Companion weather snapshot + +C2.1 uses application durable key `0x0102`; it does not change protocol 1.0 or +add a background task. The version-1 value is 14–61 bytes: version `1` (u8), WMO +weather code (u8), Celsius temperature in tenths (signed i16), observation Unix +seconds (u32), expiry Unix seconds (u32), UTF-8 place length (u8, 1–48), then the +place bytes. All integers are little-endian. Temperatures are bounded to +−100.0…100.0 °C. Codes are 0–3, 45/48, 51/53/55/56/57, 61/63/65/66/67, +71/73/75/77, 80/81/82, 85/86 and 95/96/99. Text must be valid UTF-8 without C0 +or DEL characters. Dates lie within 2000–2099, with expiry after observation +and at most six hours later; midnight 2100 is allowed only as the expiry. + +Android explicitly requests a selected city's current Open-Meteo observation +and persists it through the per-device queue before reporting it saved. The +foreground dashboard validates the received application payload and its age; +it tolerates at most five minutes of future skew and never changes the clock. +No fresh value means the normal daily line. Weather remains a static snapshot +on the final sleep surface. Clock samples continue through the authorized, +current-session Hello mailbox and are never replayed as durable weather state. + ### Cursor exchange and durable replay Hello and accepted, authorized HelloAck require TLV type `4` (sync cursors). @@ -583,3 +603,32 @@ Hardware evidence: - direct Wi-Fi success and fallback decisions; - standby, sleep, wake, shutdown and reconnect soak; - Note4 power, latency, heap and stack-watermark measurements. + +## C2.1 Android handoff and local content integration + +The Android manifest resolves `application/vnd.zectrix.enroll.v1`; onCreate and +onNewIntent share one decoder. It removes NDEF extras after consumption and +rejects multiple matching records. A supported enrollment target has a valid +public/static-random peripheral address and a nonzero device ID. CDM approval +and all existing BLE/protocol authorization remain necessary. A pending proof +is copied, target-bound, monotonic-expiring and one-use; it is never persisted, +logged or sent to the first unrelated association. The preferred approved MAC +is separate from that ephemeral material and selects the offline queue. + +Bulk content uses the screen-code-protected HTTP session, outside the small +BLE durable-value limit. Existing `/api/books` responses add optional boolean +`cover_supported`. `/api/cover` accepts PUT of the fixed 15,011-byte PBM, GET of +the committed file and DELETE after an explicit user choice. It shares books' +management lease, `.upload.part`, sync/commit, timeout and cancellation. No +implicit overwrite or install retry is added. Older companions ignore the new +capability; the new companion hides picture operations if the capability is +absent. [SLEEP_COVER.md](SLEEP_COVER.md) defines the retained surface. + +The phone HTTP client accepts literal private IPv4 destinations, refuses URL +credentials/query/fragment/base paths and redirects, and bounds request sources +and responses. Matching Wi-Fi routes support a Note4 hotspot without Internet +validation, while public weather/resource requests retain their HTTPS owners. +Android 37 local-network permission and legacy Bluetooth permissions are +explicit. Android 26–30 uses manual connect; the presence API is called only on +Android 31 and later. Physical phone outcomes remain in +[the C2.1 qualification record](qualification/C2.1-COMPANION-INTEGRATION.md). diff --git a/docs/M3_APPLICATION_CONTRACT.md b/docs/M3_APPLICATION_CONTRACT.md index d7aac1b..bcf3fc1 100644 --- a/docs/M3_APPLICATION_CONTRACT.md +++ b/docs/M3_APPLICATION_CONTRACT.md @@ -313,6 +313,10 @@ List -> Loading -> Running/Error. It owns one bounded Lua guest and copied commands inside the content viewport. Guest files are discovered in pages and do not modify the static SDK catalog. Host-render retries reuse the completed frame; Back and shutdown close both source handles and the guest. +E2.3 [packages](ZAPP_PACKAGES.md) add copied per-page metadata and bounded +header/icon reads. Launch validates compatibility, source, permissions and quota +before executing; installation through USB or Wi-Fi never creates an application +instance. These files still do not change the native SDK registry. `SceneManager` uses a static handler table and fixed storage for eight private scene IDs, eight stack entries and one `uint32_t` state value per scene. Only diff --git a/docs/MICRO_APPS.md b/docs/MICRO_APPS.md index 15c4cdb..5b54fda 100644 --- a/docs/MICRO_APPS.md +++ b/docs/MICRO_APPS.md @@ -4,6 +4,11 @@ E2.2 adds an optional **Apps** Home destination for independently installed Lua source files. Calculator and Flashcards are examples in [`apps/`](../apps/). Installing, exporting or removing one does not rebuild or reflash firmware. +E2.3 adds [`.zapp` packages and the developer CLI](ZAPP_PACKAGES.md), including +icons/metadata, per-app quotas and permissions, and USB/Wi-Fi installation. +Plain `.lua` remains compatible. The package guide describes the current +distribution format; the original guest interface below still applies. + ## Runtime choice This pilot selects **Lua 5.4.9**, one of the alternatives measured in @@ -60,9 +65,9 @@ persistent guest data API. Uploads never overwrite an existing name. An interrupted upload is discarded; an uncertain commit or remove is not retried automatically. Reconnect and -inspect `app-list`/`app-get` before repeating a mutation. Plain `.lua` sources, -not `.zapp` containers or Lua bytecode, are the pilot format. Package metadata, -stores, signing and Wi-Fi app transfer belong to later work such as E2.3. +inspect `app-list`/`app-get` before repeating a mutation. Both `.lua` sources and +E2.3 `.zapp` packages carry source compiled at launch. External Lua bytecode, +stores and signing are not supported. Wi-Fi supports both installed formats. ## Guest interface @@ -127,9 +132,10 @@ and the drawing functions shown above. | Resource | Bound / owner | | --- | --- | | Installed source | 1–32,768 bytes; UTF-8 basename of at most 47 bytes ending in `.lua` (case insensitive), no slash or control characters | +| Installed package | `.zapp`, at most 33,056 bytes including metadata and 16/32-pixel icon; same filename bound; source size remains 1–32,768 bytes | | Loading | One file and a source buffer; read at most 1 KiB per idle callback, yield one RTOS tick between slices | | Lua allocation | 128 KiB including the runtime's allocation headers; PSRAM on ESP32-S3, accounted allocation on Host; underlying heap-manager metadata is additional | -| Execution | 10,000 Lua instructions per initialization/event/render invocation; count-hook yield terminates the instance | +| Execution | At most 10,000 Lua instructions per initialization/event/render invocation; packages may request 100–10,000; count-hook yield terminates the instance | | Native parser/C calls | `LUAI_MAXCCALLS=16`; parsing also has the source/heap bounds | | Foreground work | Serial SDK callbacks; no extra task, radio, guest filesystem or background timer | | Drawing | One complete copied command buffer; only physical presentation is retried after a display failure | @@ -156,7 +162,7 @@ boundary against bugs in the native interpreter. ## Storage and USB ownership `AppStorage` is a façade over the existing `BookStorage` mount and management -lease, with physical names `.app-.lua`. Public book name validation still +lease, with physical names `.app-.lua` or `.app-.zapp`. Public book name validation still accepts only TXT/EPUB. Listings, reads, uploads and removals resolve their own namespace; app operations cannot target books. Directory scans retain only a sorted page. A previous-page scan selects the preceding bounded suffix and diff --git a/docs/SLEEP_COVER.md b/docs/SLEEP_COVER.md index a1d6794..395a9e0 100644 --- a/docs/SLEEP_COVER.md +++ b/docs/SLEEP_COVER.md @@ -1,7 +1,7 @@ # Ambient sleep cover and dashboard L1.4 adds **SLEEP COVER** to the Launcher. Choose a daily dashboard, a quiet -landscape or a blank privacy screen. The selected surface is drawn before +landscape, a blank privacy screen or a phone-supplied picture. The selected surface is drawn before power-off and remains visible without keeping the display or radios active. ## Controls @@ -28,6 +28,7 @@ controls and save-failure hint. The blank final style stays entirely white. | DAILY DASHBOARD | `0` (default) | Day, month calendar, capture time, last saved book and reading progress, and a daily line | | QUIET LANDSCAPE | `1` | Mountain illustration, daily line and capture date/time | | BLANK / PRIVACY | `2` | Entirely white, including the status and footer areas | +| PHONE PICTURE | `3` | Imported monochrome picture and wake hint; available with book storage | `StorageService` stores the unsigned 32-bit preference at `ui.sleep_cover`. A missing or invalid value uses the dashboard. A missing key needs no boot-time @@ -90,6 +91,36 @@ sleep has no button wake; use reset or power cycling. Battery-powered shutdown continues to use the board's hardware latch. Neither the retained calendar nor the quote schedules a wake. +## Companion pictures and weather + +C2.1 adds a fourth choice when file storage is enabled. In the Android companion, +choose a photo, inspect its 400×300 monochrome preview, and send it through the +current Wi-Fi Transfer session. After Finish, choose **PHONE PICTURE** locally. +The upload itself does not change the selected style or navigate the device. + +The stored file is `/books/.cover-phone.pbm`. The fixed binary PBM starts with +exactly `P4\n400 300\n`, then 15,000 row-major bytes, most significant bit first, +with one meaning black. Other headers, dimensions, lengths and partial files +are rejected. Uploads use the same exclusive lease and staging transaction as +books/apps, and cannot overwrite a saved picture. Remove the existing picture +explicitly in the companion before sending a replacement. It is excluded from +book/app lists. Missing or malformed saved artwork produces a readable missing +picture screen; a read failure during final composition uses the existing white +fallback. Each row read is 50 bytes and feeds the existing canvas, without a +new framebuffer or device image decoder. Preview retains the status bar; final +sleep displays the picture above the wake-hint band. + +The daily dashboard can replace its daily line with an explicitly requested +phone weather snapshot. The C1 queue remains its only persistent store. The +foreground copies a fresh observation before stopping connectivity; no network, +storage mutation or time calibration runs in the renderer. The observation +expires within six hours and is hidden when the clock is unset or outside its +validity window. The displayed weather stays static throughout sleep, just like +the calendar and battery snapshot. + +See [C2.1 qualification](qualification/C2.1-COMPANION-INTEGRATION.md) for the +software tests and remaining physical phone checks. + ## Verification and references ```bash diff --git a/docs/USB_HOST.md b/docs/USB_HOST.md index fdf6606..f173c8a 100644 --- a/docs/USB_HOST.md +++ b/docs/USB_HOST.md @@ -9,6 +9,9 @@ USB descriptor or public application SDK API is required. E2.2 adds independently installed [Lua micro-apps](MICRO_APPS.md) through the same owner. `app-list`, `app-put`, `app-get` and `app-remove` operate on the app namespace; the existing book commands and settings keep their behavior. +E2.3 extends these operations to [`.zapp` packages](ZAPP_PACKAGES.md). The USB +client checks packages before sending, and firmware independently validates +their headers and source while receiving. No wire operation or frame changes. ## Transport choice @@ -187,12 +190,13 @@ Transferred contents retain the existing reader's format limitations. | 10 SetSetting | u8 key, u32 value | Empty | | 11 Close | Empty | Empty, followed by the text maintenance prompt | | 12 AppList | Empty or last app filename | Same paged entry layout as List, for apps only | -| 13 AppReadOpen | App filename | u32 source size; following Read operations export this app | -| 14 AppUploadBegin | u32 source size, app filename | Empty; following UploadChunk/UploadCommit install this app | +| 13 AppReadOpen | App filename | u32 file size; following Read operations export this app | +| 14 AppUploadBegin | u32 file size, app filename | Empty; following UploadChunk/UploadCommit install this app | | 15 AppRemove | App filename | Empty after removal; rejected while a transfer is open | -App names pass `AppStorage::ValidName`: at most 47 UTF-8 bytes with `.lua` -suffix, without path/control characters. Sources contain 1–32,768 bytes. The +App names pass `AppStorage::ValidName`: at most 47 UTF-8 bytes with `.lua` or +`.zapp` suffix, without path/control characters. Sources contain 1–32,768 bytes; +packages contain at most 33,056 bytes including their header/icon. The foreground never executes code during these operations. They share the one transfer handle and storage lease with books and return `Unavailable` when the runtime module is disabled. USB Manager reports imported files and app @@ -222,7 +226,8 @@ arrays; neither framing nor drawing allocates per packet. File open and the existing stdio/SPIFFS implementation can allocate their own handles/buffers. The 64-bit Host build reports Channel 1,120 bytes, Protocol 3,328 bytes, -BookSession 2,744 bytes and UI controller 104 bytes. Channel/Protocol live in +BookSession 2,944 bytes (including E2.3 package validation) and UI controller +104 bytes. Channel/Protocol live in the Platform allocation; BookSession/controller live only with the foreground app. These are Host `sizeof` measurements, not ESP32 runtime heap readings. The existing 4 KiB CLI stack, 512-byte RX ring, 512-byte native TX ring, diff --git a/docs/ZAPP_PACKAGES.md b/docs/ZAPP_PACKAGES.md new file mode 100644 index 0000000..7c2fbf3 --- /dev/null +++ b/docs/ZAPP_PACKAGES.md @@ -0,0 +1,206 @@ +# Note4 application packages + +E2.3 adds `.zapp` distribution to the [Lua micro-app runtime](MICRO_APPS.md). +A package contains its display name, app version, author, monochrome icon, +permissions, instruction quota and source in one file. It can be installed, +exported and removed through USB Manager or the existing Send Books web page. +Full enables Apps; firmware without the runtime rejects app transfers. + +The first format carries **UTF-8 Lua source**. Lua 5.4.9 compiles it in the +bounded VM when the user opens the app. Portable source avoids host/target +bytecode representation differences and preserves the existing text-only +loader. The CLI validates the container and text; it does not compile Lua, +check Lua syntax or execute the app. Syntax and guest behavior are checked at +launch. Serialized Lua bytecode and native executables remain unsupported. + +## Build and distribute + +The CLI uses Python 3.11+ through uv and has no third-party dependencies. +Run from the repository, or add its `tools` directory to `PATH` to use `zapp`. + +```bash +tools/zapp build apps/Calculator.app.json -o build-zapp/Calculator.zapp +tools/zapp build apps/Flashcards.app.json -o build-zapp/Flashcards.zapp +tools/zapp inspect build-zapp/Calculator.zapp + +tools/zapp pack apps/Calculator.lua --icon apps/Calculator.pbm \ + --name Calculator --version 1.0.0 --author Note4 \ + --permission display --permission input --quota 10000 \ + -o build-zapp/Calculator.zapp +``` + +`uv run --script tools/zapp.py ...` is the direct equivalent. `build` accepts +a manifest path or a directory containing `app.json`; paths inside the manifest +are relative to that manifest. Without `-o`, output is `dist/.zapp` +beside the entry source. A successful local build atomically replaces its output; +a failed build preserves the old file. Device installation never overwrites. +`inspect` validates the complete file and prints JSON metadata without extracting +or running it. App filenames must fit 47 UTF-8 bytes, including `.zapp`. + +```json +{ + "format_version": 1, + "guest_api": 1, + "name": "Flashcards", + "version": "1.0.0", + "author": "Note4", + "entry": "Flashcards.lua", + "icon": "Flashcards.pbm", + "permissions": ["display", "input"], + "instruction_quota": 5000 +} +``` + +`name`, `version`, `author`, `entry`, `icon` and `permissions` are required. +`format_version` and `guest_api` default to `1`, and `instruction_quota` defaults +to `10000`. Unknown fields, unsupported permissions and duplicate permissions +are rejected to catch mistakes. Icons use 16×16 or 32×32 PBM, either ASCII P1 +or packed P4; `1` means black. The two example manifests and original icons +are in [`apps/`](../apps/). Calculator packages to 2,019 bytes and Flashcards +to 1,392 bytes, including their source. + +For USB, open **Tools > USB Manager** on Note4, then run: + +```bash +uv run --script tools/usb-manager.py ports +uv run --script tools/usb-manager.py --port PORT app-put build-zapp/Calculator.zapp +uv run --script tools/usb-manager.py --port PORT app-list +uv run --script tools/usb-manager.py --port PORT app-get Calculator.zapp exported.zapp +uv run --script tools/usb-manager.py --port PORT app-remove Calculator.zapp +``` + +For Wi-Fi, open **Send Books**, connect to its hotspot or saved network, open +the displayed address and enter the screen code. Drop `.zapp`, `.lua`, TXT +and EPUB files together and choose **Upload & finish**. The page routes each +file to its namespace, lists installed apps and supports download/deletion. +An app upload failure stops the batch and retains earlier completed files. +After leaving transfer mode, return to **Home > Apps** and press OK to launch. +The list shows package names, icons and versions; the selected app's author +appears above it. Raw `.lua` files keep their filename-based display. + +## Version 1 binary layout + +All integers are unsigned little endian. The header is exactly **160 bytes**, +followed by the icon and then the source, with no alignment gaps, compression, +archive paths, trailing data or executable section table. + +| Offset | Bytes | Field | +| --- | ---: | --- | +| 0 | 4 | ASCII `ZAPP` | +| 4 | 2 | Package format version, `1` | +| 6 | 2 | Guest API revision, `1` (`note4.api`) | +| 8 | 1 | Payload kind, `1` for Lua source | +| 9 | 1 | Icon width and height, `16` or `32` | +| 10 | 2 | Permissions: bit 0 `display`, bit 1 `input`; all other bits zero | +| 12 | 4 | Instruction quota, `100`–`10000` | +| 16 | 4 | Source byte length, `1`–`32768` | +| 20 | 4 | Reserved, zero | +| 24 | 64 | UTF-8 display name, at most 63 bytes | +| 88 | 24 | App version, at most 23 visible ASCII bytes | +| 112 | 48 | UTF-8 author, at most 47 bytes | +| 160 | 32 or 128 | Icon: top-to-bottom rows, leftmost pixel in the most significant bit | +| 192 or 288 | Source length | UTF-8 Lua source | + +The three strings are nonempty, NUL-terminated and zero-padded to their field +widths. Name and author reject ASCII control characters. The app version is a +human-readable label (for example `1.0.0`), independent of package/API versions. +Source permits tab, CR and LF but rejects other ASCII control characters, +invalid UTF-8, NUL and Lua's binary chunk escape marker. Exact lengths reject +truncation and appended data. Maximum package size is **33,056 bytes**. + +Package/API versions are checked before execution. An unsupported payload kind, +permission, reserved field, icon size or quota is rejected during installation +and checked again when opening. App version/name/author are self-declared +metadata, not publisher authentication. Filename remains the storage identity; +renaming a package installs a separate copy. There is no automatic upgrade, +downgrade, signing, store or dependency-resolution service in this format. + +## Permissions, bounds and ownership + +`display` permits the existing `note4.text`, `rect` and `fill` operations during +`on_render`. Calling one without the declaration terminates the guest with a +permission error. Without `input`, short clicks never invoke `on_event`. +`on_render` still runs and can produce an empty frame without `display`. +System Back/shutdown always remain available. There are no file, radio, timer, +OTA, raw framebuffer or native hardware capabilities to declare. + +The quota applies independently to initialization, each event and each render. +It can lower the existing 10,000-instruction ceiling and cannot raise it. +The 128 KiB Lua heap, recursion limit, copied drawing bounds and no-standard- +libraries environment remain in force. Raw `.lua` files retain both existing +permissions and the 10,000-instruction budget. USB inspection reports the actual +selected quota. Instruction accounting is not a wall-clock execution limit. + +`AppStorage` keeps the whole package at `.app-.zapp` in the existing +content partition. It shares the book mutex, management lease, 1 KiB transfer +buffers and `.upload.part` staging file. A streaming validator retains a +160-byte header and UTF-8 state, ignores icon bytes after length validation and +validates source across chunk boundaries. Invalid input poisons the upload; +publication still requires the exact length, flush/fsync, close and no existing +destination. Cancellation/error cleanup and interrupted-boot staging recovery +use the existing paths. No code executes during discovery or installation. + +Discovery reads at most 288 bytes per package into a five-entry metadata page. +Malformed on-disk entries retain their filename and open to a recoverable error. +Launch reopens and validates the header, allocates only the declared source in +PSRAM and reads it in 1 KiB idle slices. It validates source text again before +calling the text-only Lua loader. The file/source buffer close after loading; +Back during loading, errors and shutdown release them. No unpacked side files, +new task, partition, application registry or per-app framebuffer are needed. +Physical display retries keep using the copied frame without guest reexecution. + +The 64-bit Host build measures the streaming validator at 192 bytes, each +metadata entry at 276 bytes, the complete Apps controller at 5,408 bytes and +USB BookSession at 2,944 bytes (+200). These foreground objects are distinct +from the source/VM allocations and are not static ESP32 RAM measurements. + +## Design references and verification + +Reviewed Flipper's [FAP documentation][fap] and existing SceneManager/ViewPort +source on 2026-09-12. Its single-file metadata/assets and separate API identity +inform this format. Note4 retains its bounded Lua environment; it does not +import FAP's native ELF relocations or symbol-table generation. CrossPoint's +[streamed web uploads][crosspoint] and incremental reader inform fixed-chunk +transfers and foreground loading slices. Existing static sleep covers, streamed +pagination and radio ownership continue through their native owners. The wider +comparison is in [the runtime research](DYNAMIC_APPLICATION_RESEARCH.md). + +```bash +bash tools/test-zapp.sh +ZECTRIX_ZAPP_SANITIZE=1 bash tools/test-zapp.sh +ZECTRIX_RUNTIME_SANITIZE=1 bash tools/test-runtime.sh +bash tools/test-usb-manager.sh +bash tools/test-book-transfer.sh +bash tools/test-host.sh +source tools/activate-dev-env.sh +bash tools/build-firmware.sh --profile full +bash tools/build-firmware.sh --profile minimal +``` + +Tests use CLI-built packages with the independent C++ parser, real file storage, +Lua engine, USB PTY and HTTP server. Coverage includes 46 valid/malformed +containers, 16/32 icons, full-size sources, UTF-8 split across chunks, unsupported +versions/permissions, poisoned uploads, no-overwrite publication, disconnect and +cancellation cleanup, exported byte equality, app/book separation, disabled +runtime, discovery without execution, quota/permission enforcement and copied +render retries. Host simulation does not qualify physical flash power loss, +USB/RF throughput, display latency or sustained hardware execution. + +Verification on 2026-09-12: all 41 Host targets, package/runtime ASan/UBSan and +ShellCheck passed. Chrome desktop/mobile checks exercised actual drag-and-drop +of both example packages with a book, completed session shutdown, byte-exact +browser download and confirmed app deletion preserving other files. Screenshots +were inspected at 1280-pixel and 390-pixel widths. Full/Minimal builds and the +existing profile comparison passed: + +| Metric | Full | Minimal | +| --- | ---: | ---: | +| Application image | 2,498,800 B (+3,488) | 521,680 B (+32) | +| Static internal RAM | 201,815 B (unchanged) | 108,323 B (unchanged) | +| Data + BSS | 72,112 B (unchanged) | 31,824 B (unchanged) | + +This iteration did not flash hardware. Existing physical USB/RF, power-loss and +long-running device qualifications remain separate from these Host checks. + +[fap]: https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/AppsOnSDCard.md +[crosspoint]: https://github.com/crosspoint-reader/crosspoint-reader/blob/develop/src/network/CrossPointWebServer.cpp diff --git a/docs/qualification/C2.1-COMPANION-INTEGRATION.md b/docs/qualification/C2.1-COMPANION-INTEGRATION.md new file mode 100644 index 0000000..bf91550 --- /dev/null +++ b/docs/qualification/C2.1-COMPANION-INTEGRATION.md @@ -0,0 +1,122 @@ +# C2.1 companion integration evidence + +Date: 2026-09-12. This iteration delivers the Android/firmware integration and +reproducible software qualification. **Physical phone NFC and BLE qualification +remains open.** The connected ESP32-S3 boot check does not replace those tests. +GitHub #38, #39 and #48 are not closed by these results. + +## Delivered behavior + +- An installed companion resolves the Note4 NDEF MIME type from a cold launch + and through foreground dispatch. Both paths consume the intent extras once. + Ambiguous matching records, malformed payloads and unsupported peripheral + addresses are rejected before connection or association. +- A tap selects its exact public/static-random BLE address. CDM discovery is + filtered to that address; a different existing association cannot receive + its proof. The proof stays in RAM, expires on a monotonic two-minute deadline, + and is discarded after its first Hello attempt. Android still needs an + approved association, Bluetooth permissions, the Note4 pairing window, a + bonded encrypted link, notifications and protocol authorization. +- The selected approved Note4's queue loads before Bluetooth connects. Received + reading positions remain visible offline; Resume queues a durable revision. + Weather also uses the existing per-device queue. Phone time and timezone are + sampled for each Hello, rather than replayed as stale durable timestamps. +- The GATT owner waits for both bond and subscription completion before Hello. + Connection, discovery, subscription, pairing, handshake and writes have + deadlines and deterministic cleanup. Automatic retries use monotonic backoff, + stop after five retries, and require presence of the selected peer. Protocol, + enrollment and store-recovery failures require an explicit retry. Another + association disappearing cannot close the current link. +- The companion streams TXT/EPUB uploads through the existing local web API. + It validates literal private IPv4 destinations, never follows redirects, + keeps the access code only in memory, bounds source/response sizes, and does + not retry writes with uncertain outcomes. A matching Wi-Fi route is used + explicitly when Android keeps cellular as its Internet default. Android 37 + requests local-network permission; Android 26–30 retains explicit connection + without calling the newer presence API. +- Phone-selected artwork is sampled to a bounded bitmap, oriented using EXIF, + fitted onto a white 400×300 surface and dithered to a 15,011-byte PBM. A preview + is available before sending. The firmware validates the fixed header and + length while streaming, using the books partition's existing lease, staging, + sync and no-overwrite commit. A replacement requires explicit removal of the + saved picture. Sleep Cover > Phone Picture opens only the committed image, + reads 50 bytes per row into the existing canvas, and retains the wake hint. +- Weather is requested explicitly for a chosen city from Open-Meteo, without + location permission or background polling. The dashboard displays its cached + place, Celsius temperature and condition while fresh; after six hours it + returns to the daily line. A final cover is static, including its weather. + +## Executed checks + +| Check | Evidence / scope | +| --- | --- | +| Host suite | All 41 targets passed, including interrupted book/picture uploads, malformed PBM, storage exclusion, profile compositions, scene navigation, display retries and localization | +| Android JVM | 40 tests passed, with production C++ fixtures compiled by the Gradle test task | +| Cross-language enrollment | C++ NDEF → Kotlin parser and target-bound handoff → C++ proof decoder and one-use bootstrap; expired/replayed proofs, failed identity saves and identity restoration | +| Cross-language reading | 36 cases covering every data/ACK fragment cut in both directions at 20- and 182-byte ATT capacities; C++ process restart, Kotlin queue reconstruction, coalescing and convergence | +| Cross-language weather/time | Kotlin weather decoded/applied by the C++ durable engine; failed-save NACK and reconnect; clock decoding, current-session mailbox ownership and stale-sample rejection | +| Local HTTP | Kotlin client → production C++ HTTP/storage over loopback: UTF-8 books, pagination, PBM, byte-exact storage, wrong code, duplicate names, cancellation, explicit retry and finish | +| Android API 35 emulator | Five instrumentation tests: cold/warm NDEF routing and recreation, native Keystore and file persistence, image conversion/ViewModel retention, native HTTP book/picture upload through ADB reverse | +| Focused sanitizers | Storage/HTTP and display suites passed ASan/UBSan; new menu, picture, missing-picture and weather surfaces rendered and inspected | +| Firmware profiles | Full 2,501,056 bytes; Minimal 522,096 bytes. Static internal RAM remains 201,815 / 108,323 bytes; data+BSS remains 72,112 / 31,824 bytes | + +The Android emulator is `medlog_api35_lowmem`, API 35, x86_64. It supplies Android +framework, Keystore, lifecycle and socket behavior. Its NFC intent injection and +ADB-reversed HTTP connection are software tests, not antenna, BLE or WLAN evidence. +The new Android 37 permission path is compiled against SDK 37, not exercised by +this API 35 emulator. Physical phones on older/newer Android versions still need +compatibility testing. + +## Reproduce + +```bash +source tools/activate-dev-env.sh +bash tools/test-host.sh +bash tools/test-android-companion.sh +ZECTRIX_ANDROID_SERIAL=emulator-5554 bash tools/test-android-device.sh +ZECTRIX_TRANSFER_SANITIZE=1 bash tools/test-book-transfer.sh +ZECTRIX_DISPLAY_SANITIZE=1 bash tools/test-display-service.sh +bash tools/build-firmware.sh --profile full +bash tools/build-firmware.sh --profile minimal +uv run --no-project tools/compare-firmware-profiles.py build-full build-minimal \ + --output build-companion/profiles.json +# Optional connected ESP32-S3 boot verification: +bash tools/device-smoke-test.sh --profile full +``` + +`test-android-device.sh` installs the debug application and test APK on the +selected ADB target. Its temporary C++ HTTP fixture stores only test files; +its ADB reverse and server are removed on exit. The ordinary JVM task also +builds its own native fixtures, so cross-language coverage is not silently +skipped when running Gradle directly. + +## Remaining physical checks + +Use the installed debug APK, the current Full firmware and a real NFC-capable +Android phone. Keep the phone/firmware versions and observed outcomes together; +record public session/state events, never enrollment tokens or access codes. + +- [ ] With the app stopped, touch Note4's antenna. Verify Android opens the app, + the shown target matches the tapped device, CDM approves it and the user + confirms the Note4 passkey. Repeat with a different Note4 already associated. +- [ ] Repeat in the foreground, after cancelling CDM, after token expiry and + after an interrupted Hello. A consumed/replayed proof must not authorize; + reconnect after a lost accepted HelloAck must use the persisted identity. +- [ ] Turn pages on Note4, verify the phone's received position, disconnect, + queue Resume offline, kill/reopen the phone app and reconnect. Verify Note4 + offers Use phone position without navigating automatically or double-applying. +- [ ] Choose a city, reconnect and verify the Note4 clock/timezone and fresh + dashboard weather. Check stale weather fallback with a valid clock. +- [ ] With cellular enabled, join the Note4 hotspot without Internet access. + Send a TXT/EPUB and picture, finish, read the book and preview/sleep with Phone + Picture. Repeat on a shared LAN and interrupt an upload before publication. +- [ ] Exercise background presence, permission denial/regrant, Bluetooth off/on, + process death, association removal and Note4 Forget Phone/re-enrollment. For + an intentional sync reset, clear both peers' sync relationship; do not erase + just one side and expect previously acknowledged values to be reconstructed. + +CrossPoint's cooperative paging, streamed transfer and row-based sleep image +rendering inform the bounded work here. Flipper's SceneManager/ViewPort design +informs keeping Choose/Preview and final display ownership on the foreground +owner. No upstream implementation or artwork was copied. See +[FIRMWARE_UI_STUDY.md](../FIRMWARE_UI_STUDY.md) for source references. diff --git a/main/app_micro_apps.cc b/main/app_micro_apps.cc index 7b3dd82..f136582 100644 --- a/main/app_micro_apps.cc +++ b/main/app_micro_apps.cc @@ -44,7 +44,7 @@ class TerminalApp::MicroAppsApplication final : public sdk::Application { std::snprintf(state.guest_name.data(), state.guest_name.size(), "%s", controller_.name()); const auto heap = controller_.engine().heap(); state.heap_live = heap.live; state.heap_peak = heap.peak; state.heap_rejected = heap.rejected; - state.heap_limit = runtime::kHeapLimit; state.instruction_limit = runtime::kInstructionLimit; + state.heap_limit = runtime::kHeapLimit; state.instruction_limit = controller_.engine().instruction_limit(); #endif } class Library final : public app::MicroAppLibrary { diff --git a/main/app_sleep_cover.cc b/main/app_sleep_cover.cc index e711e3a..6344801 100644 --- a/main/app_sleep_cover.cc +++ b/main/app_sleep_cover.cc @@ -2,6 +2,12 @@ #include "terminal_internal.h" #include "zectrix_storage_service.h" +#include "zectrix_cover_image.h" +#if CONFIG_ZECTRIX_ENABLE_CONNECTIVITY +#include "zectrix_connectivity_service.h" +#include "zectrix_weather_sync.h" +#endif +#include #if CONFIG_ZECTRIX_ENABLE_READER #include "zectrix_reader_platform.h" #endif @@ -38,9 +44,40 @@ app::SleepCoverSnapshot TerminalApp::ReadSleepCover() { snapshot.reading.book_id = reading.book_id; snapshot.reading.progress_per_mille = reading.progress_per_mille; snapshot.has_reading = reading.state == app::ReadingOverview::State::Saved; +#if CONFIG_ZECTRIX_ENABLE_CONNECTIVITY + std::array weather_data{}; + std::size_t weather_size = weather_data.size(); + uint32_t revision = 0; + companion::WeatherSnapshot weather; + if (connectivity_ && connectivity_->ReadDurableState(companion::kWeatherSyncKey, &revision, + weather_data.data(), &weather_size) == companion::SyncStatus::kOk && + companion::DecodeWeatherSnapshot(weather_data.data(), weather_size, &weather) && weather.Fresh(time_->UnixSeconds())) { + const Text condition = weather.code == 0 ? Text::WeatherClear : weather.code <= 3 ? Text::WeatherCloudy : + weather.code <= 48 ? Text::WeatherFog : weather.code <= 57 ? Text::WeatherDrizzle : + weather.code >= 95 ? Text::WeatherStorm : + ((weather.code >= 71 && weather.code <= 77) || weather.code >= 85) ? Text::WeatherSnow : Text::WeatherRain; + const int magnitude = weather.deci_celsius < 0 ? -weather.deci_celsius : weather.deci_celsius; + std::snprintf(snapshot.weather_line.data(), snapshot.weather_line.size(), "%s %s%d.%d C %s", + weather.place.data(), weather.deci_celsius < 0 ? "-" : "", magnitude / 10, magnitude % 10, Tr(condition)); + } +#endif return snapshot; } +esp_err_t TerminalApp::PresentSleepCover(const app::SleepCoverSnapshot& snapshot, app::SleepCoverStyle style, + bool preview, bool preference_saved) { +#if CONFIG_ZECTRIX_ENABLE_BOOK_STORAGE + storage::BookFile file; + if (style == app::SleepCoverStyle::Picture && storage_ && storage_->OpenCover(&file) == ESP_OK) { + const app::SleepCoverImage picture{&file, [](void* context, uint32_t offset, void* output, std::size_t size) { + return static_cast(context)->Read(storage::cover::kHeaderSize + offset, output, size); + }}; + return ui_.ShowSleepCover(snapshot, style, preview, preference_saved, &picture); + } +#endif + return ui_.ShowSleepCover(snapshot, style, preview, preference_saved); +} + class TerminalApp::SleepCoverApplication final : public sdk::Application { public: explicit SleepCoverApplication(TerminalApp& owner) : owner_(owner) {} @@ -58,7 +95,7 @@ class TerminalApp::SleepCoverApplication final : public sdk::Application { ? owner_.ui_.ShowSleepCoverMenu(controller_.selected(), owner_.sleep_cover_style_, owner_.sleep_cover_saved_ ? nullptr : Tr(Text::CoverSaveRetry), request.intent == sdk::RenderIntent::Quality) - : owner_.ui_.ShowSleepCover(snapshot_, controller_.selected(), true, owner_.sleep_cover_saved_); + : owner_.PresentSleepCover(snapshot_, controller_.selected(), true, owner_.sleep_cover_saved_); controller_.Presented(result == ESP_OK); return ToSdkStatus(result); } diff --git a/main/terminal.cc b/main/terminal.cc index cff3ee4..1088c62 100644 --- a/main/terminal.cc +++ b/main/terminal.cc @@ -330,6 +330,8 @@ ControlResult TerminalApp::Wait(uint32_t duration_ms, bool confirm_returns) { [[noreturn]] void TerminalApp::PowerOff() { platform_.StopMaintenance(); + // Capture committed sync values before stopping their connectivity owner. + const auto sleep_snapshot = ReadSleepCover(); #if CONFIG_ZECTRIX_ENABLE_CONNECTIVITY const auto stopped = connectivity_ ? connectivity_->Stop() : zectrix::connectivity::ConnectivityResult::kOk; if (stopped != zectrix::connectivity::ConnectivityResult::kOk) { @@ -337,7 +339,7 @@ ControlResult TerminalApp::Wait(uint32_t duration_ms, bool confirm_returns) { } #endif ESP_LOGI(kTag, "presenting sleep cover before shutdown"); - const esp_err_t cover = ui_.ShowSleepCover(ReadSleepCover(), sleep_cover_style_); + const esp_err_t cover = PresentSleepCover(sleep_snapshot, sleep_cover_style_); if (cover != ESP_OK) { ESP_LOGW(kTag, "sleep cover failed: %s; attempting blank fallback", esp_err_to_name(cover)); const auto clear = ui_.ClearDisplay(); diff --git a/main/terminal_internal.h b/main/terminal_internal.h index 7fc15ff..64d61b1 100644 --- a/main/terminal_internal.h +++ b/main/terminal_internal.h @@ -101,6 +101,8 @@ class TerminalApp final : public sdk::RuntimeDelegate void LogHeap(const char* phase); ControlResult Wait(uint32_t duration_ms, bool confirm_returns); app::SleepCoverSnapshot ReadSleepCover(); + esp_err_t PresentSleepCover(const app::SleepCoverSnapshot& snapshot, app::SleepCoverStyle style, + bool preview = false, bool preference_saved = true); app::ReadingOverview ReadReadingOverview(); [[noreturn]] void PowerOff(); template void BindScenes(Controller& controller) { diff --git a/tools/book_transfer_test.cc b/tools/book_transfer_test.cc index 40270b3..9f668a9 100644 --- a/tools/book_transfer_test.cc +++ b/tools/book_transfer_test.cc @@ -1,6 +1,8 @@ #include "zectrix_book_web.h" #include "zectrix_book_storage.h" +#include "zectrix_cover_image.h" #include "zectrix_book_transfer_controller.h" +#include "sdkconfig.h" #include #include @@ -244,6 +246,131 @@ void Lifecycle(const fs::path& root) { } } +void AppHttp(const fs::path& root, const fs::path& package) { + fs::create_directory(root); + storage::BookStorage books(root.c_str()); + assert(books.BeginManagement() == ESP_OK); + BookWebApi api; + api.Start(books, kCode, 0); + const auto data = Contents(package); + assert(data.size() > 1024); + Request listing(BookHttpMethod::Get, "/api/books"); + Check(api, listing, 200); +#if CONFIG_ZECTRIX_ENABLE_RUNTIME + assert(listing.output.find("\"apps_supported\":true") != std::string::npos); + for (auto method : {BookHttpMethod::Get, BookHttpMethod::Put, BookHttpMethod::Delete}) { + Request denied(method, "/api/apps/Calculator.zapp", data); + denied.authorization.clear(); + Check(api, denied, 401); + assert(!denied.offset); + } + Request installed(BookHttpMethod::Put, "/api/apps/Calculator.zapp", data); + Check(api, installed, 200); + assert(installed.max_read <= 1024 && Contents(root / ".app-Calculator.zapp") == data); + assert(api.Progress().uploaded == 1); + Request duplicate(BookHttpMethod::Put, "/api/apps/Calculator.zapp", data); + Check(api, duplicate, 409); + Request exported(BookHttpMethod::Get, "/api/apps/Calculator.zapp"); + Check(api, exported, 200); + assert(exported.output == data && exported.attachment == "Calculator.zapp"); + Request book_list(BookHttpMethod::Get, "/api/books"), app_list(BookHttpMethod::Get, "/api/apps"); + Check(api, book_list, 200); Check(api, app_list, 200); + assert(book_list.output.find("Calculator") == std::string::npos); + assert(app_list.output.find("Calculator.zapp") != std::string::npos); + for (const auto* path : {"/api/books/Calculator.zapp", "/api/apps/book.txt", "/api/apps/.zapp", + "/api/apps/%2e%2e%2fescape.zapp", "/api/apps?after=book.txt"}) { + Request invalid(BookHttpMethod::Get, path); + Check(api, invalid, 400); + } + Request oversized(BookHttpMethod::Put, "/api/apps/large.zapp"); + oversized.content_length = 33057; + Check(api, oversized, 413); + for (int fault = 0; fault < 5; ++fault) { + auto damaged = data; + if (fault == 0) damaged[8] = 2; + if (fault == 1) damaged[10] = 4; + if (fault == 2) damaged.back() = '\xc2'; + Request interrupted(BookHttpMethod::Put, "/api/apps/bad.zapp", damaged); + if (fault == 3) interrupted.fail_after = 200; + if (fault == 4) interrupted.after_read = [&] { api.Cancel(); }; + Check(api, interrupted, fault < 3 ? 400 : 408); + assert(!fs::exists(root / ".app-bad.zapp") && !fs::exists(root / ".upload.part")); + api.Start(books, kCode, 0); + } + Request retry(BookHttpMethod::Put, "/api/apps/bad.zapp", data); + Check(api, retry, 200); + Request raw(BookHttpMethod::Put, "/api/apps/Plain.lua", "while true do end"); + Check(api, raw, 200); + for (unsigned i = 0; i < 35; ++i) { + char name[32]; std::snprintf(name, sizeof(name), ".app-A%03u.zapp", i); + std::ofstream(root / name, std::ios::binary).write(data.data(), data.size()); + } + Request first(BookHttpMethod::Get, "/api/apps"), next(BookHttpMethod::Get, "/api/apps?after=A031.zapp"); + Check(api, first, 200); Check(api, next, 200); + assert(first.output.find("A031.zapp") != std::string::npos && first.output.find("A032.zapp") == std::string::npos); + assert(next.output.find("A032.zapp") != std::string::npos && next.output.find("\"more\":false") != std::string::npos); + Request remove(BookHttpMethod::Delete, "/api/apps/Calculator.zapp"); + Check(api, remove, 200); + assert(!fs::exists(root / ".app-Calculator.zapp")); +#else + assert(listing.output.find("\"apps_supported\":false") != std::string::npos); + for (auto method : {BookHttpMethod::Get, BookHttpMethod::Put, BookHttpMethod::Delete}) { + Request disabled(method, "/api/apps/Calculator.zapp", data); + Check(api, disabled, 503); + assert(!disabled.offset && !fs::exists(root / ".upload.part")); + } +#endif + assert(books.EndManagement() == ESP_OK); +} + +void CoverHttp(const fs::path& root) { + fs::create_directory(root); + storage::BookStorage books(root.c_str()); + assert(books.BeginManagement() == ESP_OK); + BookWebApi api; + api.Start(books, kCode, 0); + const std::string image = std::string(storage::cover::kHeader) + std::string(storage::cover::kPixelBytes, '\x55'); + for (const auto size : {std::size_t(0), image.size() - 1, image.size() + 1}) { + Request wrong_size(BookHttpMethod::Put, "/api/cover", std::string(size, 'x')); + Check(api, wrong_size, 400); + assert(!fs::exists(root / ".upload.part")); + } + Request malformed(BookHttpMethod::Put, "/api/cover", image); + malformed.body[1] = '6'; malformed.fragment = 1; + Check(api, malformed, 400); + assert(!fs::exists(root / ".cover-phone.pbm") && !fs::exists(root / ".upload.part")); + Request cancelled(BookHttpMethod::Put, "/api/cover", image); + cancelled.fail_after = 1024; + Check(api, cancelled, 408); + assert(!fs::exists(root / ".cover-phone.pbm") && !fs::exists(root / ".upload.part")); + Request upload(BookHttpMethod::Put, "/api/cover", image); + upload.fragment = 1; + Check(api, upload, 200); + assert(Contents(root / ".cover-phone.pbm") == image); + Request duplicate(BookHttpMethod::Put, "/api/cover", image); + Check(api, duplicate, 409); + assert(Contents(root / ".cover-phone.pbm") == image); + Request download(BookHttpMethod::Get, "/api/cover"); + Check(api, download, 200); + assert(download.output == image); + Request listing(BookHttpMethod::Get, "/api/books"); + Check(api, listing, 200); + assert(listing.output.find("phone.pbm") == std::string::npos); + storage::BookFile picture; + assert(books.OpenCover(&picture) == ESP_ERR_INVALID_STATE); + assert(books.EndManagement() == ESP_OK); + assert(books.OpenCover(&picture) == ESP_OK && picture.Size() == image.size()); + assert(books.BeginManagement() == ESP_ERR_INVALID_STATE); + picture.Close(); + std::ofstream(root / ".cover-phone.pbm", std::ios::binary | std::ios::trunc) << "bad image"; + assert(books.OpenCover(&picture) == ESP_ERR_INVALID_SIZE); + assert(books.BeginManagement() == ESP_OK); + Request remove(BookHttpMethod::Delete, "/api/cover"); + Check(api, remove, 200); + assert(!fs::exists(root / ".cover-phone.pbm")); + assert(books.EndManagement() == ESP_OK); +} + void Scenes() { using namespace app; constexpr sdk::InputEvent ok{sdk::Button::Ok, sdk::InputAction::Click}; @@ -290,9 +417,12 @@ void Scenes() { } // namespace int main(int argc, char** argv) { - assert(argc == 2); + assert(argc == 3); + fs::create_directories(argv[1]); StorageAndHttp(fs::path(argv[1]) / "http"); Lifecycle(fs::path(argv[1]) / "lifecycle"); Scenes(); + CoverHttp(fs::path(argv[1]) / "covers"); + AppHttp(fs::path(argv[1]) / "apps", argv[2]); std::puts("PASS: streamed web books, interrupted uploads, storage isolation, radio lifecycle and transfer scenes."); } diff --git a/tools/book_web_integration_test.mjs b/tools/book_web_integration_test.mjs index 17002d8..677cafd 100644 --- a/tools/book_web_integration_test.mjs +++ b/tools/book_web_integration_test.mjs @@ -1,6 +1,6 @@ import { mkdir, readdir } from 'node:fs/promises'; -const [binary, root] = Bun.argv.slice(2); +const [binary, root, packageFile] = Bun.argv.slice(2); const code = 'ABCDEFGH2345'; const assert = (condition, message) => { if (!condition) throw new Error(message); }; await mkdir(root); @@ -41,9 +41,26 @@ try { let response = await request('api/books'); let listing = await response.json(); assert(listing.files.length === 32 && listing.more && listing.files[31].name === 'A031.txt', 'First library page is wrong.'); + assert(listing.apps_supported === true, 'App support is not advertised.'); listing = await (await request('api/books?after=A031.txt')).json(); assert(listing.files.length === 8 && !listing.more, 'Second library page is wrong.'); + const app = new Uint8Array(await Bun.file(packageFile).arrayBuffer()); + response = await request('api/apps/Calculator.zapp', { method: 'PUT', body: app }); + assert(response.ok && (await response.json()).ok, 'Package upload failed.'); + const exportedApp = new Uint8Array(await (await request('api/apps/Calculator.zapp')).arrayBuffer()); + assert(exportedApp.length === app.length && exportedApp.every((value, i) => value === app[i]), 'Package export changed bytes.'); + const appList = await (await request('api/apps')).json(); + assert(appList.files.length === 1 && appList.files[0].name === 'Calculator.zapp', 'Installed app is missing.'); + response = await request('api/apps/Calculator.zapp', { method: 'PUT', body: app }); + assert(response.status === 409, 'Duplicate package replaced an app.'); await response.text(); + const invalidApp = app.slice(); invalidApp[6] = 2; + response = await request('api/apps/bad.zapp', { method: 'PUT', body: invalidApp }); + assert(response.status === 400, 'Unsupported guest API was installed.'); await response.text(); + assert(!(await readdir(root)).includes('.app-bad.zapp'), 'Malformed app became visible.'); + response = await request('api/apps/Calculator.zapp', { method: 'DELETE' }); + assert(response.ok, 'App removal failed.'); await response.text(); + const name = '书"&稿.epub'; const body = Uint8Array.from({ length: 131072 }, (_, i) => i % 256); response = await request(`api/books/${encodeURIComponent(name)}`, { method: 'PUT', body }); diff --git a/tools/build-companion-fixtures.sh b/tools/build-companion-fixtures.sh new file mode 100755 index 0000000..0808d7d --- /dev/null +++ b/tools/build-companion-fixtures.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +root_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +work_dir=${1:-"$root_dir/build-companion/fixtures"} +mkdir -p "$work_dir" +companion="$root_dir/components/zectrix_companion" +connectivity="$root_dir/components/zectrix_connectivity" +flags=(-std=c++17 -Wall -Wextra -Werror -pedantic -pthread) +"${CXX:-c++}" "${flags[@]}" -I"$companion/include" \ + "$companion/zectrix_companion_protocol.cc" "$companion/zectrix_companion_identity.cc" \ + "$companion/zectrix_enrollment_ndef.cc" "$companion/zectrix_pairing_bootstrap.cc" \ + "$companion/zectrix_sync_engine.cc" "$companion/zectrix_sync_session.cc" \ + "$root_dir/tools/companion_peer_host.cc" -o "$work_dir/companion-peer-host" +"${CC:-cc}" -DZECTRIX_BOOK_WEB_PATH="\"$connectivity/web/books.html\"" \ + -c "$connectivity/zectrix_book_web_data.S" -o "$work_dir/web.o" +"${CXX:-c++}" "${flags[@]}" \ + -I"$root_dir/tools/host_include" -I"$connectivity/include" -I"$companion/include" \ + -I"$root_dir/components/zectrix_storage/include" \ + "$connectivity/zectrix_book_web.cc" "$connectivity/zectrix_book_transfer.cc" "$connectivity/zectrix_wifi_backend.cc" \ + "$root_dir/components/zectrix_storage/zectrix_book_storage.cc" \ + "$root_dir/tools/book_web_host.cc" "$work_dir/web.o" -o "$work_dir/book-web-host" +echo 'Built production C++ companion and local HTTP fixtures.' diff --git a/tools/companion_peer_host.cc b/tools/companion_peer_host.cc new file mode 100644 index 0000000..1f2e66c --- /dev/null +++ b/tools/companion_peer_host.cc @@ -0,0 +1,205 @@ +#include "zectrix_companion_identity.h" +#include "zectrix_companion_protocol.h" +#include "zectrix_clock_sync.h" +#include "zectrix_enrollment_ndef.h" +#include "zectrix_pairing_bootstrap.h" +#include "zectrix_sync_session.h" +#include "zectrix_weather_sync.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A process boundary for the production codecs, bootstrap and durable engine. +// The test transport deliberately has no Bluetooth or RF implementation. +namespace { +using namespace zectrix::companion; +std::string Hex(const uint8_t* bytes, std::size_t size) { + constexpr char digits[] = "0123456789abcdef"; + std::string result; + for (std::size_t i = 0; i < size; ++i) { result += digits[bytes[i] >> 4]; result += digits[bytes[i] & 15]; } + return result; +} +std::vector Unhex(const std::string& value) { + assert(value.size() % 2 == 0 && value.size() <= kMaximumFrameSize * 2); + std::vector result; + for (std::size_t i = 0; i < value.size(); i += 2) result.push_back(std::stoul(value.substr(i, 2), nullptr, 16)); + return result; +} +class FileStore final : public SyncStore { +public: + explicit FileStore(std::string path) : path_(std::move(path)) {} + StoreReadStatus Load(uint8_t* output, std::size_t capacity, std::size_t* size) override { + if (!std::filesystem::exists(path_)) return StoreReadStatus::kNotFound; + const auto length = std::filesystem::file_size(path_); + if (length > capacity) return StoreReadStatus::kError; + std::ifstream input(path_, std::ios::binary); + input.read(reinterpret_cast(output), length); + *size = length; + return input ? StoreReadStatus::kOk : StoreReadStatus::kError; + } + bool Save(const uint8_t* input, std::size_t size) override { + if (fail_next) { fail_next = false; return false; } + const auto temporary = path_ + ".tmp"; + auto* file = std::fopen(temporary.c_str(), "wb"); + if (!file) return false; + const bool saved = std::fwrite(input, 1, size, file) == size && std::fflush(file) == 0 && fsync(fileno(file)) == 0; + const bool closed = std::fclose(file) == 0; + if (!saved || !closed || std::rename(temporary.c_str(), path_.c_str()) != 0) return false; + ++writes; + return true; + } + unsigned writes = 0; + bool fail_next = false; +private: + std::string path_; +}; +class Clock final : public PairingBootstrapClock { +public: + uint32_t now = 0; + uint32_t MonotonicMilliseconds() const override { return now; } +}; +class Random final : public PairingBootstrapRandom { +public: + bool Fill(BootstrapToken* token) override { + // Reproducible fixture bytes are never linked into firmware. + for (unsigned i = 0; i < token->size(); ++i) (*token)[i] = 0x31 + i + generation; + ++generation; + return true; + } + unsigned generation = 0; +}; +class Sender final : public SyncFrameSender { +public: + LinkResult SendSyncFrame(const uint8_t* frame, std::size_t size) override { + bytes.assign(frame, frame + size); return LinkResult::kOk; + } + std::vector bytes; +}; +} // namespace + +int main(int argc, char** argv) { + assert(argc == 2); + std::filesystem::create_directories(argv[1]); + FileStore store(std::string(argv[1]) + "/sync.bin"); + FileStore identity(std::string(argv[1]) + "/identity.bin"); + SyncEngine engine; + assert(engine.Initialize(store) == SyncStatus::kOk); + SyncSession session(engine); + FragmentReassembler fragments; + Clock clock; Random random; PairingBootstrap bootstrap(clock, random); + uint32_t sequence = 1; + uint16_t frame_id = 1; + std::string line; + while (std::getline(std::cin, line)) { + assert(line.size() < 10000); + std::istringstream input(line); + std::string command, value; + input >> command; + if (command == "cursors") { + std::array bytes{}; + std::size_t size = 0; + assert(EncodeSyncCursors(engine.Cursors(), bytes.data(), bytes.size(), &size) == ProtocolStatus::kOk); + std::cout << Hex(bytes.data(), size); + } else if (command == "start") { + input >> value; + const auto bytes = Unhex(value); SyncCursors cursors; + assert(DecodeSyncCursors(bytes.data(), bytes.size(), &cursors) == ProtocolStatus::kOk); + std::cout << static_cast(session.Start(cursors, clock.now)); + sequence = 1; fragments.Reset(); + } else if (command == "put") { + unsigned key, revision; input >> key >> revision >> value; + const auto bytes = Unhex(value); + std::cout << static_cast(engine.PutDurableState(key, revision, bytes.data(), bytes.size())); + } else if (command == "packet") { + input >> value; const auto bytes = Unhex(value); + const auto status = fragments.Accept(bytes.data(), bytes.size()); + if (status == ProtocolStatus::kFrameComplete) { + FrameView frame; + assert(DecodeFrame(fragments.Data(), fragments.Size(), 1, 0, &frame) == ProtocolStatus::kOk); + std::cout << (session.Receive(frame) ? "ok" : "rejected"); + } else { + assert(status == ProtocolStatus::kFragmentAccepted); + std::cout << "partial"; + } + } else if (command == "poll") { + unsigned packet_size; input >> clock.now >> packet_size; + Sender sender; + session.Poll(sender, sequence, clock.now); + if (sender.bytes.empty()) std::cout << "none"; + else { + const auto count = FragmentCount(sender.bytes.size(), packet_size); + for (std::size_t i = 0; i < count; ++i) { + std::array packet{}; std::size_t size = 0; + assert(EncodeFragment(sender.bytes.data(), sender.bytes.size(), frame_id, i, packet_size, + packet.data(), packet.size(), &size) == ProtocolStatus::kOk); + if (i) std::cout << ','; + std::cout << Hex(packet.data(), size); + } + ++frame_id; + } + } else if (command == "read") { + unsigned key; input >> key; DurableStateView state; + if (engine.ReadIncomingState(key, &state) != SyncStatus::kOk) std::cout << "missing"; + else std::cout << state.revision << ' ' << Hex(state.value, state.value_size); + } else if (command == "converged") std::cout << (session.Converged() ? "yes" : "no"); + else if (command == "writes") std::cout << store.writes; + else if (command == "fail-save") { store.fail_next = true; std::cout << "ok"; } + else if (command == "ndef") { + assert(bootstrap.Prepare() == BootstrapStatus::kOk); + BootstrapMaterial material; + assert(bootstrap.Material(&material) == BootstrapStatus::kOk); + EnrollmentNdefPayload payload; + payload.flags = kEnrollmentNdefFlagBleAddressValid; + payload.ble_address_type = 0; + payload.ble_address = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; + payload.device_id.fill(0x42); + payload.generation = material.generation; payload.token = material.token; + std::vector bytes(EnrollmentNdefMessageSize()); std::size_t size = 0; + assert(EncodeEnrollmentNdefMessage(payload, bytes.data(), bytes.size(), &size) == EnrollmentNdefStatus::kOk); + std::cout << Hex(bytes.data(), size); + } else if (command == "field") std::cout << static_cast(bootstrap.OpenPairingWindow()); + else if (command == "time") { input >> clock.now; std::cout << "ok"; } + else if (command == "proof") { + unsigned fail = 0; input >> value >> fail; + const auto bytes = Unhex(value); + uint32_t generation = 0; uint8_t token[16]{}, peer[16]{}; + assert(DecodeEnrollmentProofValue(bytes.data(), bytes.size(), &generation, token, peer) == ProtocolStatus::kOk); + std::cout << static_cast(bootstrap.ValidateAndPersistEnrollmentProof(42, generation, token, sizeof(token), [&] { + CompanionIdentityRecord record{0x3150435aU, 1, 0, {}, generation}; + std::memcpy(record.companion_id, peer, sizeof(peer)); + std::array stored{}; + EncodeCompanionIdentityRecord(record, stored.data()); + identity.fail_next = fail != 0; + return identity.Save(stored.data(), stored.size()); + })); + } else if (command == "identity") { + std::array stored{}; std::size_t size = 0; + CompanionIdentityRecord record; + if (identity.Load(stored.data(), stored.size(), &size) == StoreReadStatus::kOk && + DecodeCompanionIdentityRecord(stored.data(), size, &record)) std::cout << Hex(record.companion_id, 16); + else std::cout << "missing"; + } else if (command == "clock") { + unsigned authorized, age; input >> value >> authorized >> age; + const auto bytes = Unhex(value); ClockSample sample; + assert(DecodeClockSampleValue(bytes.data(), bytes.size(), &sample) == ProtocolStatus::kOk); + ClockMailbox mailbox; mailbox.Offer(sample, 42, 0); + if (mailbox.Take(authorized ? 42 : 0, age, &sample)) + std::cout << sample.unix_milliseconds << ' ' << sample.utc_offset_seconds; + else std::cout << "rejected"; + } else if (command == "weather") { + int64_t now; input >> value >> now; + const auto bytes = Unhex(value); WeatherSnapshot weather; + if (DecodeWeatherSnapshot(bytes.data(), bytes.size(), &weather) && weather.Fresh(now)) + std::cout << weather.deci_celsius << ' ' << unsigned(weather.code) << ' ' << weather.place.data(); + else std::cout << "rejected"; + } else assert(false); + std::cout << std::endl; + } +} diff --git a/tools/display_service_test.cc b/tools/display_service_test.cc index f04702b..2242aa8 100644 --- a/tools/display_service_test.cc +++ b/tools/display_service_test.cc @@ -1957,6 +1957,44 @@ void TestSleepCoverComposition() { assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Quote) == ESP_OK); assert(std::memcmp(cover.data(), ui.canvas().data(), cover.size()) == 0); + struct PictureFixture { + std::array bytes{}; + std::size_t largest_read = 0; + bool fail = false; + } picture_fixture; + for (std::size_t i = 0; i < picture_fixture.bytes.size(); ++i) picture_fixture.bytes[i] = i % 50 < 25 ? 0xaa : 0x55; + const SleepCoverImage picture{&picture_fixture, [](void* context, uint32_t offset, void* output, std::size_t size) { + auto& fixture = *static_cast(context); + assert(offset <= fixture.bytes.size() && size <= fixture.bytes.size() - offset); + fixture.largest_read = std::max(fixture.largest_read, size); + if (fixture.fail) return false; + std::memcpy(output, fixture.bytes.data() + offset, size); + return true; + }}; + assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Picture, true, true, &picture) == ESP_OK); + assert(picture_fixture.largest_read == 50); + for (std::size_t i = 24 * 50; i < 273 * 50; ++i) + assert(ui.canvas().data()[i] == static_cast(~picture_fixture.bytes[i])); + SavePreview(ui.canvas(), "sleep-picture-preview"); + assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Picture, false, true, &picture) == ESP_OK); + for (std::size_t i = 0; i < 273 * 50; ++i) + assert(ui.canvas().data()[i] == static_cast(~picture_fixture.bytes[i])); + SavePreview(ui.canvas(), "sleep-picture"); + std::memcpy(cover.data(), ui.canvas().data(), cover.size()); + ClearTraffic(); + ++status.minute; + ui.UpdateStatus(status); + assert(ui.RefreshPending() == ESP_OK && packets.empty()); + assert(std::memcmp(cover.data(), ui.canvas().data(), cover.size()) == 0); + picture_fixture.fail = true; + assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Picture, true, true, &picture) == ESP_FAIL); + assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Picture, true) == ESP_OK); + SavePreview(ui.canvas(), "sleep-picture-missing"); + std::strcpy(snapshot.weather_line.data(), "Shanghai 18.5 C Cloudy"); + assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Dashboard) == ESP_OK); + SavePreview(ui.canvas(), "sleep-weather"); + snapshot.weather_line.fill(0); + snapshot.reading.progress_per_mille = UINT16_MAX; assert(ui.ShowSleepCover(snapshot, SleepCoverStyle::Dashboard) == ESP_OK); assert(!Bit(ui.canvas().data(), 50, 382, 231)); diff --git a/tools/micro_app_test.cc b/tools/micro_app_test.cc index bae7ea5..55c0234 100644 --- a/tools/micro_app_test.cc +++ b/tools/micro_app_test.cc @@ -304,8 +304,118 @@ static void StyledViewport() { } } +static void Packages(const fs::path& root, const fs::path& packages, const fs::path& previews) { + fs::create_directory(root); + storage::BookStorage books(root.c_str()); + const auto calculator = Read(packages / "Calculator.zapp"), cards = Read(packages / "Flashcards.zapp"); + Install(books, "A.zapp", calculator); + Install(books, "B.ZAPP", cards); + Library library(books); + app::MicroAppController controller(library); + assert(sdk::IsOk(controller.Start())); + assert(library.opens == 2 && controller.engine().heap().live == 0); + assert(std::string(controller.metadata(0)->name.data()) == "Calculator"); + assert(controller.metadata(0)->icon_side == 16 && controller.metadata(1)->icon_side == 32); + Click(controller); Loaded(controller); + assert(controller.scene() == MicroAppScene::Running && std::string(controller.title()) == "Calculator"); + assert(controller.engine().instruction_limit() == 10000); + Back(controller); Click(controller, sdk::Button::Down); Click(controller); Loaded(controller); + assert(controller.scene() == MicroAppScene::Running && controller.engine().instruction_limit() == 5000); + Click(controller); + assert(HasText(controller.engine().frame(), "Electrophoretic display")); + Preview(controller, previews / "packaged-flashcards.pbm"); + controller.Stop(); + assert(books.BeginManagement() == ESP_OK); + assert(library.apps.Remove("A.zapp") == BookWriteResult::Ok && library.apps.Remove("B.ZAPP") == BookWriteResult::Ok); + assert(books.EndManagement() == ESP_OK); + + auto replace = [&](const std::string& source, unsigned quota, unsigned permissions) { + controller.Stop(); + std::string package = calculator.substr(0, 192); + for (unsigned i = 0; i < 4; ++i) { + package[12 + i] = static_cast(quota >> (8 * i)); + package[16 + i] = static_cast(source.size() >> (8 * i)); + } + package[10] = static_cast(permissions); + package += source; + assert(books.BeginManagement() == ESP_OK); + const auto removed = library.apps.Remove("App.zapp"); + assert(removed == BookWriteResult::Ok || removed == BookWriteResult::NotFound); + assert(books.EndManagement() == ESP_OK); + Install(books, "App.zapp", package); + assert(sdk::IsOk(controller.Start())); + assert(controller.engine().heap().live == 0 && controller.metadata(0)); + Click(controller); + }; + replace("local n=0; function on_event() return true end; function on_render() n=n+1; note4.text(0,0,n) end", 1000, 3); + Loaded(controller); + assert(HasText(controller.engine().frame(), "1")); + controller.Presented(false); + for (int i = 0; i < 8; ++i) controller.Tick(); + assert(HasText(controller.engine().frame(), "1")); + Click(controller); + assert(HasText(controller.engine().frame(), "2")); + + replace("function on_render() note4.text(0,0,'static') end; function on_event() while true do end end", 1000, 1); + Loaded(controller); Click(controller); + assert(controller.scene() == MicroAppScene::Running && HasText(controller.engine().frame(), "static")); + replace("function on_render() note4.fill(0,0,10,10) end", 1000, 0); + Loaded(controller); + assert(controller.scene() == MicroAppScene::Error && controller.engine().error() == runtime::Error::Permission); + replace("while true do end", 100, 3); + Loaded(controller); + assert(controller.scene() == MicroAppScene::Error && controller.engine().error() == runtime::Error::Instructions); + assert(controller.engine().heap().live == 0); + + const std::string source = "function on_render() end"; + replace(source + std::string(runtime::kSourceLimit - source.size(), ' '), 10000, 3); + controller.Tick(); + assert(controller.busy() && books.BeginManagement() == ESP_ERR_INVALID_STATE); + Back(controller); + assert(controller.engine().heap().live == 0 && books.BeginManagement() == ESP_OK); + assert(books.EndManagement() == ESP_OK); + Click(controller); Loaded(controller); + assert(controller.scene() == MicroAppScene::Running); + controller.Stop(); + + // Disk corruption is checked again at launch, including externally placed files. + auto corrupt = calculator; + corrupt[6] = 2; + std::ofstream(root / ".app-App.zapp", std::ios::binary | std::ios::trunc).write(corrupt.data(), corrupt.size()); + assert(sdk::IsOk(controller.Start()) && !controller.metadata(0)); + Click(controller); Loaded(controller); + assert(controller.scene() == MicroAppScene::Error && controller.storage_result() == ESP_ERR_INVALID_ARG); + assert(controller.engine().heap().live == 0 && books.BeginManagement() == ESP_OK); + assert(books.EndManagement() == ESP_OK); + controller.Stop(); + corrupt = calculator; + corrupt[192] = 0; + std::ofstream(root / ".app-App.zapp", std::ios::binary | std::ios::trunc).write(corrupt.data(), corrupt.size()); + assert(sdk::IsOk(controller.Start()) && controller.metadata(0)); + Click(controller); Loaded(controller); + assert(controller.scene() == MicroAppScene::Error && controller.storage_result() == ESP_ERR_INVALID_ARG); + assert(controller.engine().heap().live == 0); + controller.Stop(); + + ZectrixCanvas icons; + icons.Clear(); + package::Metadata meta; + meta.icon_side = 32; + meta.icon[4] = 0x40; + icons.SetClip({20, 20, 16, 16}); + ui::DrawMicroAppIcon(icons, meta, 20, 20); + assert((icons.data()[20 * 50 + 20 / 8] & (0x80 >> (20 % 8))) == 0); + for (int y = 0; y < 300; ++y) for (int x = 0; x < 400; ++x) { + const bool ink = !(icons.data()[y * 50 + x / 8] & (0x80 >> (x % 8))); + assert(ink == (x == 20 && y == 20)); + } + ui::DrawMicroAppIcon(icons, meta, 20, 20, 16, true); + assert((icons.data()[20 * 50 + 20 / 8] & (0x80 >> (20 % 8))) != 0); + assert(icons.clip().x == 20 && icons.clip().width == 16); +} + int main(int argc, char** argv) { - assert(argc == 3); + assert(argc == 4); char pattern[] = "/tmp/note4-micro-apps-XXXXXX"; const auto* temporary = mkdtemp(pattern); assert(temporary); @@ -314,6 +424,7 @@ int main(int argc, char** argv) { Scenes(root, argv[1], argv[2]); Faults(root / "faults", argv[2]); StyledViewport(); + Packages(root / "packages", argv[3], argv[2]); fs::remove_all(root); std::cout << "Micro-apps: storage isolation, paging, pilots, exit, recovery, clipped views and 100 lifetimes passed.\n"; } diff --git a/tools/runtime-host/CMakeLists.txt b/tools/runtime-host/CMakeLists.txt index 258750e..3dc4c39 100644 --- a/tools/runtime-host/CMakeLists.txt +++ b/tools/runtime-host/CMakeLists.txt @@ -28,5 +28,5 @@ target_include_directories(micro_app_test PRIVATE "${repo}/tools/host_include" target_compile_definitions(micro_app_test PRIVATE CONFIG_ZECTRIX_ENABLE_READER=0) target_compile_options(micro_app_test PRIVATE -Wall -Wextra -Werror) target_link_libraries(micro_app_test PRIVATE note4_runtime) -add_test(NAME micro_apps COMMAND micro_app_test "${repo}/apps" "${CMAKE_CURRENT_BINARY_DIR}") +add_test(NAME micro_apps COMMAND micro_app_test "${repo}/apps" "${CMAKE_CURRENT_BINARY_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/packages") set_tests_properties(micro_apps PROPERTIES TIMEOUT 30) diff --git a/tools/runtime_test.cc b/tools/runtime_test.cc index 00dc9ff..f445dec 100644 --- a/tools/runtime_test.cc +++ b/tools/runtime_test.cc @@ -9,8 +9,8 @@ using namespace zectrix::runtime; -static bool Start(Engine& engine, const std::string& source) { - return engine.Start(reinterpret_cast(source.data()), source.size()); +static bool Start(Engine& engine, const std::string& source, Options options = {}) { + return engine.Start(reinterpret_cast(source.data()), source.size(), options); } static std::string Read(const std::string& path) { std::ifstream file(path); @@ -124,5 +124,35 @@ int main(int argc, char** argv) { assert(Start(engine, "function on_render() if io or os or package or debug or coroutine or load or setmetatable then note4.text(0,0,'unsafe') else note4.text(0,0,'restricted') end end")); assert(engine.Draw() && HasText(engine.frame(), "restricted")); engine.Stop(); + for (const int quota : {0, 99, 10001, -1}) { + assert(!Start(engine, cards, {quota, true, true})); + assert(engine.error() == Error::InvalidSource && engine.heap().live == 0); + } + const std::string loop = "local n = 0; for i = 1,1000 do n = n + i end; "; + for (int phase = 0; phase < 3; ++phase) { + const auto source = (phase == 0 ? loop : "") + std::string("function on_event() ") + + (phase == 1 ? loop : "") + "end; function on_render() " + (phase == 2 ? loop : "") + "end"; + assert(Start(engine, source) && engine.Handle(Key::Ok) && engine.Draw()); + const auto started = Start(engine, source, {100, true, true}); + if (phase == 0) assert(!started); + else { + assert(started); + assert(!(phase == 1 ? engine.Handle(Key::Ok) : engine.Draw())); + } + assert(engine.error() == Error::Instructions && engine.heap().live == 0); + } + assert(Start(engine, "function on_event() while true do end end; function on_render() note4.text(0,0,'static') end", + {10000, true, false})); + assert(engine.Draw() && engine.Handle(Key::Ok) && !engine.redraw_requested() && engine.active()); + assert(HasText(engine.frame(), "static")); + for (const char* drawing : {"note4.text(0,0,'denied')", "note4.rect(0,0,1,1)", "note4.fill(0,0,1,1)"}) { + assert(Start(engine, std::string("note4.permissions = 3; function on_render() ") + drawing + " end", + {10000, false, true})); + assert(!engine.Draw() && engine.error() == Error::Permission && engine.heap().live == 0); + } + assert(Start(engine, "function on_render() end; function on_event() note4.exit() end", {100, false, true})); + assert(engine.Draw() && engine.Handle(Key::Ok) && engine.exit_requested()); + assert(Start(engine, cards) && engine.instruction_limit() == kInstructionLimit && engine.Draw()); + engine.Stop(); std::cout << "Runtime: pilots, quota, malformed code, draw bounds and 1024 fault/restart lifetimes passed.\n"; } diff --git a/tools/sleep_cover_test.cc b/tools/sleep_cover_test.cc index 957b54f..8dd7123 100644 --- a/tools/sleep_cover_test.cc +++ b/tools/sleep_cover_test.cc @@ -12,6 +12,7 @@ int main() { assert(SleepCoverSetting(0) == SleepCoverStyle::Dashboard); assert(SleepCoverSetting(1) == SleepCoverStyle::Quote); assert(SleepCoverSetting(2) == SleepCoverStyle::Blank); + assert(SleepCoverSetting(3) == SleepCoverStyle::Picture); assert(SleepCoverSetting(UINT32_MAX) == kSleepCoverDefault); time::ClockSnapshot clock{{2026, 9, 9, 6, 20, 27, 0}, time::ClockSource::Rtc}; @@ -46,7 +47,7 @@ int main() { constexpr sdk::InputEvent down{sdk::Button::Down, sdk::InputAction::Click}; constexpr sdk::InputEvent up{sdk::Button::Up, sdk::InputAction::Click}; constexpr sdk::InputEvent back{sdk::Button::Ok, sdk::InputAction::LongPress}; - for (const auto style : {SleepCoverStyle::Dashboard, SleepCoverStyle::Quote, SleepCoverStyle::Blank}) { + for (const auto style : {SleepCoverStyle::Dashboard, SleepCoverStyle::Quote, SleepCoverStyle::Blank, SleepCoverStyle::Picture}) { SleepCoverController controller; assert(sdk::IsOk(controller.Start(style)) && controller.selected() == style); assert(controller.Tick() == SleepCoverDecision::None); diff --git a/tools/test-android-device.sh b/tools/test-android-device.sh new file mode 100755 index 0000000..4aabd54 --- /dev/null +++ b/tools/test-android-device.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +root_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +serial=${ZECTRIX_ANDROID_SERIAL:-$(adb get-serialno)} +work_dir=$(mktemp -d) +server_pid="" +port="" +cleanup() { + if [ -n "$port" ]; then adb -s "$serial" reverse --remove "tcp:$port" >/dev/null 2>&1 || true; fi + if [ -n "$server_pid" ]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi + rm -rf "$work_dir" +} +trap cleanup EXIT +"$root_dir/android-companion/gradlew" --no-daemon -p "$root_dir/android-companion" \ + :app:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest +mkdir -p "$work_dir/books" +"$root_dir/android-companion/build/companion-fixtures/book-web-host" "$work_dir/books" >"$work_dir/server.log" & +server_pid=$! +for ((attempt = 0; attempt < 100; ++attempt)); do + if rg -q '^READY ' "$work_dir/server.log"; then break; fi + kill -0 "$server_pid" + sleep 0.05 +done +port=$(sed -nE 's|^READY http://127.0.0.1:([0-9]+)/.*|\1|p' "$work_dir/server.log") +if [ -z "$port" ]; then cat "$work_dir/server.log" >&2; exit 1; fi +adb -s "$serial" reverse "tcp:$port" "tcp:$port" +adb -s "$serial" install -r "$root_dir/android-companion/app/build/outputs/apk/debug/app-debug.apk" +adb -s "$serial" install -r "$root_dir/android-companion/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" +if [ "$(adb -s "$serial" shell getprop ro.build.version.sdk | tr -d '\r')" -ge 37 ]; then + adb -s "$serial" shell pm grant dev.zectrix.note4.companion android.permission.ACCESS_LOCAL_NETWORK +fi +adb -s "$serial" shell am instrument -w -r -e transferUrl "http://127.0.0.1:$port" \ + dev.zectrix.note4.companion.test/androidx.test.runner.AndroidJUnitRunner | tee "$work_dir/results.log" +rg -q '^OK \([0-9]+ tests?\)' "$work_dir/results.log" +echo 'PASS: Android intent, Keystore, durable storage, image conversion and native HTTP integration.' +echo 'NFC antenna, BLE pairing and background presence still require a physical Note4 and phone.' diff --git a/tools/test-book-transfer.sh b/tools/test-book-transfer.sh index 9ee51f6..061dabf 100755 --- a/tools/test-book-transfer.sh +++ b/tools/test-book-transfer.sh @@ -10,17 +10,21 @@ if [ "${ZECTRIX_TRANSFER_SANITIZE:-0}" = 1 ]; then fi "${CC:-cc}" -DZECTRIX_BOOK_WEB_PATH="\"$connectivity/web/books.html\"" \ -c "$connectivity/zectrix_book_web_data.S" -o "$work_dir/web.o" -"${CXX:-c++}" -std=c++17 "${flags[@]}" \ - -I"$root_dir/tools/host_include" -I"$connectivity/include" \ - -I"$root_dir/components/zectrix_storage/include" \ - -I"$root_dir/components/zectrix_companion/include" \ - -I"$root_dir/components/zectrix_app/include" -I"$root_dir/components/zectrix_text/include" \ - "$connectivity/zectrix_book_web.cc" "$connectivity/zectrix_book_transfer.cc" "$connectivity/zectrix_wifi_backend.cc" \ - "$root_dir/components/zectrix_storage/zectrix_book_storage.cc" \ - "$root_dir/components/zectrix_app/zectrix_scene_manager.cc" \ - "$root_dir/components/zectrix_app/zectrix_book_transfer_controller.cc" \ - "$root_dir/tools/book_transfer_test.cc" "$work_dir/web.o" -o "$work_dir/test" -"$work_dir/test" "$work_dir" +"$root_dir/tools/zapp" build "$root_dir/apps/Calculator.app.json" -o "$work_dir/Calculator.zapp" +for runtime in 1 0; do + "${CXX:-c++}" -std=c++17 "${flags[@]}" \ + -DCONFIG_ZECTRIX_ENABLE_RUNTIME="$runtime" \ + -I"$root_dir/tools/host_include" -I"$connectivity/include" \ + -I"$root_dir/components/zectrix_storage/include" \ + -I"$root_dir/components/zectrix_companion/include" \ + -I"$root_dir/components/zectrix_app/include" -I"$root_dir/components/zectrix_text/include" \ + "$connectivity/zectrix_book_web.cc" "$connectivity/zectrix_book_transfer.cc" "$connectivity/zectrix_wifi_backend.cc" \ + "$root_dir/components/zectrix_storage/zectrix_book_storage.cc" \ + "$root_dir/components/zectrix_app/zectrix_scene_manager.cc" \ + "$root_dir/components/zectrix_app/zectrix_book_transfer_controller.cc" \ + "$root_dir/tools/book_transfer_test.cc" "$work_dir/web.o" -o "$work_dir/test" + "$work_dir/test" "$work_dir/runtime-$runtime" "$work_dir/Calculator.zapp" +done "${CXX:-c++}" -std=c++17 "${flags[@]}" \ -I"$root_dir/tools/host_include" -I"$connectivity/include" \ -I"$root_dir/components/zectrix_storage/include" -I"$root_dir/components/zectrix_companion/include" \ @@ -34,4 +38,4 @@ fi "$connectivity/zectrix_book_web.cc" "$connectivity/zectrix_book_transfer.cc" "$connectivity/zectrix_wifi_backend.cc" \ "$root_dir/components/zectrix_storage/zectrix_book_storage.cc" \ "$root_dir/tools/book_web_host.cc" "$work_dir/web.o" -o "$work_dir/book-web-host" -bun "$root_dir/tools/book_web_integration_test.mjs" "$work_dir/book-web-host" "$work_dir/network" +bun "$root_dir/tools/book_web_integration_test.mjs" "$work_dir/book-web-host" "$work_dir/network" "$work_dir/Calculator.zapp" diff --git a/tools/test-host.sh b/tools/test-host.sh index 01d0d4b..5aae691 100755 --- a/tools/test-host.sh +++ b/tools/test-host.sh @@ -43,6 +43,7 @@ host_tests=( test-utilities.sh test-wifi-backend.sh test-wifi-http.sh + test-zapp.sh ) "$repo_root/tools/check-architecture-boundaries.sh" --self-test diff --git a/tools/test-runtime.sh b/tools/test-runtime.sh index ba88971..1783611 100755 --- a/tools/test-runtime.sh +++ b/tools/test-runtime.sh @@ -7,6 +7,8 @@ if [ "${ZECTRIX_RUNTIME_SANITIZE:-0}" = 1 ]; then build="$repo/build-runtime-host-sanitized" flags="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer" fi +"$repo/tools/zapp" build "$repo/apps/Calculator.app.json" -o "$build/packages/Calculator.zapp" +"$repo/tools/zapp" build "$repo/apps/Flashcards.app.json" -o "$build/packages/Flashcards.zapp" args=(-S "$repo/tools/runtime-host" -B "$build" -DCMAKE_BUILD_TYPE=Debug "-DCMAKE_C_FLAGS=$flags" "-DCMAKE_CXX_FLAGS=$flags" "-DCMAKE_EXE_LINKER_FLAGS=$flags") if [ -n "${ZECTRIX_LUA_SOURCE_DIR:-}" ]; then diff --git a/tools/test-zapp.sh b/tools/test-zapp.sh new file mode 100755 index 0000000..5cf1ca1 --- /dev/null +++ b/tools/test-zapp.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +flags=(-std=c++17 -O1 -Wall -Wextra -Werror -pthread) +if [ "${ZECTRIX_ZAPP_SANITIZE:-0}" = 1 ]; then + flags+=(-g "-fsanitize=address,undefined" -fno-sanitize-recover=all -fno-omit-frame-pointer) +fi +"${CXX:-c++}" "${flags[@]}" -I"$repo/tools/host_include" -I"$repo/components/zectrix_storage/include" \ + "$repo/components/zectrix_storage/zectrix_book_storage.cc" "$repo/tools/zapp_test.cc" -o "$work/test" +uv run --no-project "$repo/tools/zapp_test.py" "$work/test" diff --git a/tools/usb-manager.py b/tools/usb-manager.py index 4ee8584..466a12f 100644 --- a/tools/usb-manager.py +++ b/tools/usb-manager.py @@ -16,6 +16,7 @@ import serial from serial.tools import list_ports +from zapp import PACKAGE_LIMIT, PackageError, inspect_package HEADER = struct.Struct("<4sBBHII") PAYLOAD_SIZE = 1024 @@ -27,7 +28,7 @@ "applied for this boot, but not saved; repeat the setting to retry") SETTINGS = {"language": 0, "auto_showcase": 1, "sleep_cover": 2} VALUES = {"language": {"en": 0, "zh-CN": 1}, "auto_showcase": {"off": 0, "on": 1}, - "sleep_cover": {"dashboard": 0, "landscape": 1, "blank": 2}} + "sleep_cover": {"dashboard": 0, "landscape": 1, "blank": 2, "picture": 3}} class HostError(Exception): @@ -214,14 +215,21 @@ def put(self, source, name=None, progress=None, *, application=False): name_limit = 47 if application else 63 if not 0 < len(name) <= name_limit or not source.is_file(): raise HostError(f"use a regular file and a name of at most {name_limit} UTF-8 bytes") - if application and not name.lower().endswith(b".lua"): - raise HostError("micro-apps must be Lua source files ending in .lua") + packaged = application and name.lower().endswith(b".zapp") + if application and not (packaged or name.lower().endswith(b".lua")): + raise HostError("micro-apps must be .zapp packages or .lua source files") with source.open("rb") as file: size = os.fstat(file.fileno()).st_size if size > 0xFFFFFFFF: raise HostError("file exceeds the device size limit") - if application and not 0 < size <= 32768: - raise HostError("micro-app source must contain 1–32768 bytes") + if application and not 0 < size <= (PACKAGE_LIMIT if packaged else 32768): + raise HostError("micro-app exceeds its package or source size limit") + if packaged: + try: + inspect_package(file.read(PACKAGE_LIMIT + 1)) + except PackageError as error: + raise HostError(f"invalid .zapp: {error}") from error + file.seek(0) self.request(APP_BEGIN if application else BEGIN, struct.pack("= capacity or any(ord(c) < 32 or ord(c) == 127 for c in value): + raise PackageError(f"{label} must fit {capacity - 1} UTF-8 bytes without control characters") + if ascii_only and any(not 33 <= ord(c) <= 126 for c in value): + raise PackageError(f"{label} must use visible ASCII characters") + return encoded.ljust(capacity, b"\0") + + +def source_text(source): + if not 0 < len(source) <= SOURCE_LIMIT: + raise PackageError(f"Lua source must contain 1–{SOURCE_LIMIT} bytes") + try: + text = source.decode("utf-8") + except UnicodeError as error: + raise PackageError("payload must be UTF-8 Lua source, not bytecode") from error + if any((ord(c) < 32 and c not in "\t\n\r") or ord(c) == 127 for c in text): + raise PackageError("Lua source contains a control byte or binary chunk") + + +def bounded_read(path, limit): + with Path(path).open("rb") as file: + data = file.read(limit + 1) + if len(data) > limit: + raise PackageError(f"{path}: exceeds {limit} bytes") + return data + + +def read_icon(path): + data = bounded_read(path, 16384) + cursor = 0 + + def token(): + nonlocal cursor + while cursor < len(data): + if data[cursor] in b" \t\r\n\v\f": + cursor += 1 + elif data[cursor] == ord("#"): + end = data.find(b"\n", cursor) + cursor = len(data) if end < 0 else end + 1 + else: + break + start = cursor + while cursor < len(data) and data[cursor] not in b" \t\r\n\v\f#": + cursor += 1 + return data[start:cursor] + + magic, width, height = token(), token(), token() + if magic not in (b"P1", b"P4") or width not in (b"16", b"32") or height != width: + raise PackageError("icon must be a 16×16 or 32×32 monochrome PBM (P1 or P4)") + side = int(width) + if magic == b"P4": + if cursor == len(data) or data[cursor] not in b" \t\r\n\v\f": + raise PackageError("missing PBM raster separator") + cursor += 2 if data[cursor:cursor + 2] == b"\r\n" else 1 + pixels = data[cursor:] + if len(pixels) != side * side // 8: + raise PackageError("incorrect PBM raster length") + return side, pixels + pixels = bytearray(side * side // 8) + for bit in range(side * side): + value = token() + if value not in (b"0", b"1"): + raise PackageError("PBM must have exactly one 0 or 1 per pixel") + if value == b"1": + pixels[bit // 8] |= 0x80 >> (bit % 8) + if token(): + raise PackageError("unexpected pixels after the PBM raster") + return side, bytes(pixels) + + +def make_package(source, icon, manifest): + source_text(source) + quota = manifest.get("instruction_quota", 10000) + if type(quota) is not int or not 100 <= quota <= 10000: + raise PackageError("instruction_quota must be an integer from 100 to 10000") + for key in ("format_version", "guest_api"): + value = manifest.get(key, 1) + if type(value) is not int or value != 1: + raise PackageError(f"only {key} 1 is supported") + permissions = manifest.get("permissions") + if not isinstance(permissions, list) or any(not isinstance(p, str) or p not in PERMISSIONS for p in permissions): + raise PackageError("permissions must be a list containing only 'display' and/or 'input'") + if len(permissions) != len(set(permissions)): + raise PackageError("duplicate permission") + side, pixels = icon + if side not in (16, 32) or len(pixels) != side * side // 8: + raise PackageError("incorrect icon dimensions or length") + return HEADER.pack(b"ZAPP", 1, 1, 1, side, sum(PERMISSIONS[p] for p in permissions), + quota, len(source), 0, + text_field(manifest.get("name"), 64, "name"), + text_field(manifest.get("version"), 24, "version", ascii_only=True), + text_field(manifest.get("author"), 48, "author")) + pixels + source + + +def inspect_package(data): + if not HEADER.size + 33 <= len(data) <= PACKAGE_LIMIT: + raise PackageError("invalid package length") + magic, version, api, kind, side, permissions, quota, size, reserved, name, app_version, author = HEADER.unpack_from(data) + if magic != b"ZAPP" or version != 1 or api != 1 or kind != 1 or reserved: + raise PackageError("unsupported package format, guest API, payload kind or flags") + if side not in (16, 32) or permissions & ~3 or not 100 <= quota <= 10000: + raise PackageError("invalid icon size, permission declaration or instruction quota") + offset = HEADER.size + side * side // 8 + if len(data) != offset + size: + raise PackageError("truncated package or unexpected trailing bytes") + + def field(raw, label, ascii_only=False): + try: + value = raw.split(b"\0", 1)[0].decode("utf-8") + if text_field(value, len(raw), label, ascii_only=ascii_only) != raw: + raise PackageError(f"{label} has nonzero padding") + return value + except UnicodeError as error: + raise PackageError(f"{label} is not UTF-8") from error + + source_text(data[offset:]) + return {"format_version": version, "guest_api": api, "payload": "lua-source", + "name": field(name, "name"), "version": field(app_version, "version", True), + "author": field(author, "author"), "permissions": [p for p, flag in PERMISSIONS.items() if permissions & flag], + "instruction_quota": quota, "icon_size": side, "source_bytes": size, "package_bytes": len(data)} + + +def publish(data, destination): + destination = Path(destination) + name = destination.name + if (not name.lower().endswith(".zapp") or name.lower() == ".zapp" or + len(name.encode("utf-8")) > 47 or any(ord(c) < 32 or ord(c) == 127 or c == "\\" for c in name)): + raise PackageError("output must have a .zapp filename of at most 47 UTF-8 bytes") + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=".zapp-", delete=False) as file: + temporary = Path(file.name) + file.write(data) + file.flush() + os.fsync(file.fileno()) + os.replace(temporary, destination) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def build_manifest(path): + path = Path(path) + if path.is_dir(): + path /= "app.json" + manifest = json.loads(bounded_read(path, 8192)) + allowed = {"name", "version", "author", "entry", "icon", "permissions", "instruction_quota", "format_version", "guest_api"} + if not isinstance(manifest, dict) or manifest.keys() - allowed: + raise PackageError("manifest must be an object with documented app fields") + for key in ("entry", "icon"): + if not isinstance(manifest.get(key), str) or not manifest[key]: + raise PackageError(f"manifest requires a {key} path") + source = path.parent / manifest["entry"] + if source.suffix.lower() != ".lua": + raise PackageError("entry must name a .lua source file") + return make_package(bounded_read(source, SOURCE_LIMIT), read_icon(path.parent / manifest["icon"]), manifest), source + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Package Note4 apps; Lua is compiled by the device at launch") + commands = parser.add_subparsers(dest="command", required=True) + build = commands.add_parser("build", help="build from a JSON manifest or a directory containing app.json") + build.add_argument("manifest", type=Path, nargs="?", default=Path(".")) + build.add_argument("-o", "--output", type=Path) + pack = commands.add_parser("pack", help="package source, PBM icon and explicit metadata") + pack.add_argument("source", type=Path) + pack.add_argument("--name", required=True) + pack.add_argument("--version", required=True) + pack.add_argument("--author", required=True) + pack.add_argument("--icon", required=True, type=Path) + pack.add_argument("--permission", choices=PERMISSIONS, action="append", default=[]) + pack.add_argument("--quota", type=int, default=10000) + pack.add_argument("-o", "--output", type=Path) + commands.add_parser("inspect", help="validate a package and print its metadata as JSON").add_argument("package", type=Path) + args = parser.parse_args(argv) + try: + if args.command == "inspect": + metadata = inspect_package(bounded_read(args.package, PACKAGE_LIMIT)) + else: + if args.command == "build": + data, source = build_manifest(args.manifest) + else: + source = args.source + data = make_package(bounded_read(source, SOURCE_LIMIT), read_icon(args.icon), { + "name": args.name, "version": args.version, "author": args.author, + "permissions": args.permission, "instruction_quota": args.quota}) + metadata = inspect_package(data) + output = args.output or source.parent / "dist" / (source.stem + ".zapp") + publish(data, output) + metadata["output"] = str(output) + print(json.dumps(metadata, ensure_ascii=False)) + return 0 + except (PackageError, OSError, UnicodeError, json.JSONDecodeError) as error: + print(f"zapp: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/zapp_test.cc b/tools/zapp_test.cc new file mode 100644 index 0000000..3baea79 --- /dev/null +++ b/tools/zapp_test.cc @@ -0,0 +1,68 @@ +#include "zectrix_app_storage.h" + +#include +#include +#include +#include +#include +#include + +using namespace zectrix; +namespace fs = std::filesystem; + +int main(int argc, char** argv) { + assert(argc == 3); + fs::create_directories(argv[2]); + storage::BookStorage books(argv[2]); + storage::AppStorage apps(books); + assert(apps.ValidName("计算器.ZAPP") && !books.ValidName("Calculator.zapp")); + assert(!apps.ValidName(".zapp") && !apps.ValidName("../Bad.zapp")); + assert(apps.ValidName((std::string(42, 'a') + ".zapp").c_str())); + assert(!apps.ValidName((std::string(43, 'a') + ".zapp").c_str())); + unsigned count = 0; + for (const auto& entry : fs::directory_iterator(argv[1])) { + if (entry.path().extension() != ".zapp") continue; + std::ifstream input(entry.path(), std::ios::binary); + const std::string data{std::istreambuf_iterator(input), {}}; + const bool valid = entry.path().filename().string().rfind("valid-", 0) == 0; + for (const unsigned chunk : {1, 7, 159, 160, 161, 192, 1020}) { + package::Validator validator; + validator.Reset(data.size()); + bool accepted = true; + for (std::size_t offset = 0; offset < data.size() && accepted; offset += chunk) + accepted = validator.Feed(data.data() + offset, std::min(chunk, data.size() - offset)); + if ((accepted && validator.Complete()) != valid) { + std::cerr << entry.path() << " chunk=" << chunk << '\n'; + return 1; + } + assert(!validator.Feed("x", 1)); + assert(!validator.Complete()); + } + using Result = storage::BookWriteResult; + assert(books.BeginManagement() == ESP_OK); + storage::BookUpload upload; + auto result = apps.BeginUpload("Fixture.zapp", data.size(), &upload); + for (std::size_t offset = 0; offset < data.size() && result == Result::Ok; offset += 137) + result = upload.Write(data.data() + offset, std::min(137, data.size() - offset)); + if (result == Result::Ok) result = upload.Commit(); + assert((result == Result::Ok) == valid); + if (!valid) assert(upload.Commit() != Result::Ok); + upload.Abort(); + assert(!fs::exists(fs::path(argv[2]) / ".upload.part")); + assert(fs::exists(fs::path(argv[2]) / ".app-Fixture.zapp") == valid); + if (valid) { + assert(apps.BeginUpload("Fixture.zapp", data.size(), &upload) == Result::Exists); + storage::BookFile file; + assert(apps.OpenManaged("Fixture.zapp", &file) == ESP_OK); + assert(books.EndManagement() == ESP_ERR_INVALID_STATE); + std::string copy(data.size(), '\0'); + assert(file.Read(0, copy.data(), copy.size()) && copy == data); + file.Close(); + assert(apps.Remove("Fixture.zapp") == Result::Ok); + } + assert(books.EndManagement() == ESP_OK); + ++count; + } + assert(count > 40); + std::cout << "Packages: " << count << " independent fixtures, fragmented validation and transactional installation passed.\n"; +} diff --git a/tools/zapp_test.py b/tools/zapp_test.py new file mode 100644 index 0000000..e78289a --- /dev/null +++ b/tools/zapp_test.py @@ -0,0 +1,115 @@ +"""Exercise CLI output against both the Python reader and the device streaming parser.""" + +import json +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest + +import zapp + +REPO = Path(__file__).resolve().parent.parent +BINARY = sys.argv.pop(1) + + +class Packages(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="note4-zapp-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + def cli(self, *args, success=True): + result = subprocess.run([sys.executable, str(REPO / "tools/zapp.py"), *map(str, args)], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0 if success else 1, result.stderr) + return json.loads(result.stdout) if success else result.stderr + + def test_build_pack_inspect_and_rebuild(self): + for name, side, quota in (("Calculator", 16, 10000), ("Flashcards", 32, 5000)): + output = self.root / "with spaces" / f"{name}.zapp" + meta = self.cli("build", REPO / f"apps/{name}.app.json", "-o", output) + self.assertEqual((meta["name"], meta["icon_size"], meta["instruction_quota"]), (name, side, quota)) + first = output.read_bytes() + self.cli("pack", REPO / f"apps/{name}.lua", "--icon", REPO / f"apps/{name}.pbm", + "--name", name, "--version", "1.0.0", "--author", "Note4", "--permission", "display", + "--permission", "input", "--quota", quota, "-o", output) + self.assertEqual(output.read_bytes(), first) + self.assertEqual(self.cli("inspect", output)["package_bytes"], len(first)) + self.assertFalse(list(output.parent.glob(".zapp-*"))) + + def test_manifest_failures_preserve_output(self): + manifest = json.loads((REPO / "apps/Calculator.app.json").read_text()) + manifest.update(entry=str(REPO / "apps/Calculator.lua"), icon=str(REPO / "apps/Calculator.pbm")) + path, output = self.root / "app.json", self.root / "existing.zapp" + output.write_bytes(b"keep") + for change in ({"instruction_quota": 10001}, {"instruction_quota": True}, {"guest_api": 2}, + {"format_version": 0}, {"permissions": ["network"]}, {"permissions": ["display", "display"]}, + {"name": "x" * 64}, {"name": "bad\nname"}, {"version": "1.0 beta"}, + {"author": ""}, {"entry": None}, {"unknown": 1}): + path.write_text(json.dumps(manifest | change)) + self.cli("build", self.root, "-o", output, success=False) + self.assertEqual(output.read_bytes(), b"keep") + path.write_text(json.dumps(manifest)) + self.cli("build", self.root, "-o", output) + self.assertNotEqual(output.read_bytes(), b"keep") + path.write_text("[]") + self.cli("build", path, success=False) + path.write_text("{") + self.cli("build", path, success=False) + + def test_icons_and_source_bounds(self): + path = self.root / "icon.pbm" + pixels = b"# \n\r" + bytes(range(28)) + for separator in (b"\n", b"\r\n"): + path.write_bytes(b"P4\n# original icon\n16 16" + separator + pixels) + self.assertEqual(zapp.read_icon(path), (16, pixels)) + for data in (b"P4\n16 16\n" + pixels + b"x", b"P4\n16 16\n" + pixels[:-1], + b"P1\n16 32\n", b"P1\n16 16\n" + b"0 " * 255, b"P1\n16 16\n" + b"0 " * 257): + path.write_bytes(data) + with self.assertRaises(zapp.PackageError): + zapp.read_icon(path) + for source in (b"", b" " * 32769, b"\x1bLua", b"-- \xff", b"--\0", b"--\x7f", b"--\xed\xa0\x80"): + with self.assertRaises(zapp.PackageError): + zapp.source_text(source) + + def test_device_parser_and_installation(self): + fixtures = self.root / "fixtures" + fixtures.mkdir() + original, _ = zapp.build_manifest(REPO / "apps/Calculator.app.json") + cases = {} + cases["valid-calculator"] = original + cases["valid-flashcards"], _ = zapp.build_manifest(REPO / "apps/Flashcards.app.json") + for side in (16, 32): + text = "-- 中文\n".encode() + b" " * (32768 - len("-- 中文\n".encode())) + cases[f"valid-max-{side}"] = zapp.make_package(text, (side, bytes(side * side // 8)), { + "name": "便携应用", "version": "1.2.3-preview+4", "author": "开发者", "permissions": [], "instruction_quota": 100}) + def changed(offset, data): + return original[:offset] + data + original[offset + len(data):] + for offset, data in ((0, b"NOPE"), (4, b"\x02\0"), (6, b"\x02\0"), (8, b"\x02"), + (9, b"\0"), (9, b"\x18"), (10, b"\x04\0"), (11, b"\x80"), + (12, struct.pack("