Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/sdkconfig
/sdkconfig.old
/managed_components/
/apps/dist/
/android-companion/.gradle/
/android-companion/.kotlin/
/android-companion/**/build/
Expand All @@ -14,4 +15,3 @@ __pycache__/
*.py[cod]
*.log
.astra_*

6 changes: 5 additions & 1 deletion ASTRA_TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
- **背景与愿景**:
Expand Down
52 changes: 48 additions & 4 deletions android-companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions android-companion/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test>().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)
}
Original file line number Diff line number Diff line change
@@ -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<android.app.Application>()
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<MainActivity>(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() }
}
}
13 changes: 12 additions & 1 deletion android-companion/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,37 @@
android:name="android.software.companion_device_setup"
android:required="true" />

<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND" />
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />

<application
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:label="Zectrix Companion"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/vnd.zectrix.enroll.v1" />
</intent-filter>
</activity>

<service
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dev.zectrix.note4.companion

import android.companion.CompanionDeviceManager
import android.content.Context
import android.os.Build

object ApprovedDevices {
@Suppress("DEPRECATION")
fun addresses(context: Context): List<String> = 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()
}
}
Loading