refactor: split android overlay out of storyforge

This commit is contained in:
kris
2026-03-26 10:41:33 +08:00
parent dd619448e7
commit 8d62da7e91
44 changed files with 279 additions and 9541 deletions

2
.gitignore vendored
View File

@@ -25,8 +25,6 @@ node_modules/
# Runtime data and artifacts # Runtime data and artifacts
data/ data/
!android-app/app/src/main/java/com/aiglasses/app/data/
!android-app/app/src/main/java/com/aiglasses/app/data/**
output/ output/
*.log *.log

View File

@@ -3,10 +3,11 @@
StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。 StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。 仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。
拆分治理方案见:[StoryForge / AI Glasses 拆分评估方案](./docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
`AI-glasses` 独立代码仓库已单独维护在 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
## 目录 ## 目录
- `android-app/`StoryForge Android 客户端
- `collector-service/`FastAPI 后端负责用户体系、项目、Agent、任务、内容分析和对外能力接入 - `collector-service/`FastAPI 后端负责用户体系、项目、Agent、任务、内容分析和对外能力接入
- `n8n/`:工作流导出文件,作为流程编排中枢 - `n8n/`:工作流导出文件,作为流程编排中枢
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排 - `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
@@ -20,14 +21,7 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
- [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md) - [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md)
- [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md) - [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前工作台仅 `douyin` 完整实现) - [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前工作台仅 `douyin` 完整实现)
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md) - [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)(仅 UI 原型,不代表当前仓库承载 Android 工程)
## Android
```bash
cd /Users/kris/code/StoryForge-gitea/android-app
./gradlew assembleDebug
```
## Douyin Browser Capture ## Douyin Browser Capture

View File

@@ -1,35 +0,0 @@
# StoryForge Android App
StoryForge Android client for the current workspace entry: authentication, content import, agent management, production tracking, and OTA install.
## Current flow
- Compose-based StoryForge shell
- Secure session storage for base URL and token
- Backend API calls for login, project/content import, agent management, and update checks
- Local video picking for learning tasks
- OTA download and install from the "我的" tab
## Default backend
The app defaults to:
`https://storyforge.hyzq.net`
For local development, cleartext HTTP is only allowed for `localhost`, `127.0.0.1`, and `10.0.2.2`.
## Build APK
Open this folder in Android Studio:
`/Users/kris/code/StoryForge-gitea/android-app`
Then run:
```bash
./gradlew assembleDebug
```
APK output:
`app/build/outputs/apk/debug/app-debug.apk`

View File

@@ -1,86 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.aiglasses.app"
compileSdk = 35
defaultConfig {
applicationId = "com.storyforge.app"
minSdk = 26
targetSdk = 35
versionCode = 37
versionName = "0.6.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "DEFAULT_STORYFORGE_BASE_URL", "\"https://storyforge.hyzq.net\"")
buildConfigField("String", "DEFAULT_STORYFORGE_FALLBACK_IP", "\"111.231.132.51\"")
buildConfigField("String", "DEFAULT_LOCAL_MODEL_BASE_URL", "\"http://127.0.0.1:8317/v1\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation(files("libs/brtc-3.5.0.1a.aar"))
implementation(files("libs/lib_agent-1.0.1.4.aar"))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

View File

@@ -1,2 +0,0 @@
# Keep default for demo stage.

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.AIGlasses">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -1,51 +0,0 @@
package com.aiglasses.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiglasses.app.storyforge.StoryForgeScreen
import com.aiglasses.app.storyforge.StoryForgeViewModel
import com.aiglasses.app.ui.theme.AIGlassesTheme
import com.aiglasses.app.update.AppOtaUpdater
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AIGlassesTheme {
val vm: StoryForgeViewModel = viewModel()
val state by vm.state.collectAsState()
val otaUpdater = AppOtaUpdater(this) { vm.onOtaLog(it) }
DisposableEffect(Unit) {
otaUpdater.register()
onDispose { otaUpdater.release() }
}
val videoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
} ?: (uri.lastPathSegment ?: "selected-video.mp4")
vm.setPickedVideo(uri, fileName)
}
}
StoryForgeScreen(
state = state,
vm = vm,
onPickVideo = { videoPicker.launch(arrayOf("video/*")) },
onInstallLatestUpdate = { vm.installLatestUpdate(otaUpdater) }
)
}
}
}
}

View File

@@ -1,638 +0,0 @@
package com.aiglasses.app.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.ParcelUuid
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
import java.util.UUID
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
private const val MAX_FRAME_DATA = 8 * 1024
data class BleLinkState(
val scanning: Boolean = false,
val connected: Boolean = false,
val notificationsReady: Boolean = false,
val handshaked: Boolean = false,
val deviceName: String = "",
val deviceAddress: String = "",
val devUuid: String = "",
val lastError: String = ""
)
sealed interface GlassesBleEvent {
data class Log(val message: String) : GlassesBleEvent
data class HandshakeOk(
val devUuid: String,
val devName: String,
val devFwVer: String
) : GlassesBleEvent
data class StatusUpdate(val payloadJson: String) : GlassesBleEvent
data class AudioFrame(val bytes: ByteArray, val index: Int) : GlassesBleEvent
data class CameraThumbInfo(val sourceFileName: String, val isVideo: Boolean) : GlassesBleEvent
data class CameraThumbData(val bytes: ByteArray, val index: Int, val isVideo: Boolean) : GlassesBleEvent
}
private data class HichipsFrame(
val command: Int,
val index: Int,
val payload: ByteArray
)
private object HichipsUuid {
val service3D20: UUID = shortUuid("3d20")
val char3D21Notify: UUID = shortUuid("3d21")
val char3D22NotifyData: UUID = shortUuid("3d22")
val char3D23Write: UUID = shortUuid("3d23")
val service5DC0: UUID = shortUuid("5dc0")
val char5DC1Notify: UUID = shortUuid("5dc1")
val char5DC2NotifyData: UUID = shortUuid("5dc2")
val char5DC3Write: UUID = shortUuid("5dc3")
val cccd: UUID = shortUuid("2902")
private fun shortUuid(hex: String): UUID {
return UUID.fromString("0000${hex.lowercase()}-0000-1000-8000-00805f9b34fb")
}
}
private object HichipsCmd {
// 5DC0 wake-up stream commands
const val ASR_DEV_WAKE_UP = 0x0000
const val ASR_APP_WAKE_UP = 0x0001
const val ASR_TRANS_SETTING = 0x0002
const val ASR_TRANS_START = 0x0003
const val ASR_TRANS_FLOW_CTRL = 0x0004
const val ASR_TRANS_AUDIO = 0x0005
const val ASR_TRANS_APP_SET_STOP = 0x0006
const val ASR_TRANS_STOP = 0x0007
// 3D20 common commands
const val AG_HS_DEV_UUID = 0x0000
const val AG_HS_APP_UUID = 0x0001
const val AG_HS_DEV_INFO = 0x0002
const val AG_GET_ALL_STATUS = 0x0013
const val AG_P_TAKE_START = 0x00A0
const val AG_P_TAKE_STOP = 0x00A1
const val AG_P_THUMB_INFO = 0x00A2
const val AG_P_THUMB_DATA = 0x00A3
const val AG_V_THUMB_INFO = 0x0094
const val AG_V_THUMB_DATA = 0x0095
}
private class FrameAssembler {
private var buffer = byteArrayOf()
private val head = byteArrayOf(0x48, 0x49, 0x43, 0x48) // HICH
private val end = byteArrayOf(0x49, 0x50, 0x53, 0x45) // IPSE
fun append(chunk: ByteArray): List<HichipsFrame> {
if (chunk.isEmpty()) return emptyList()
buffer += chunk
val out = mutableListOf<HichipsFrame>()
while (true) {
val start = indexOf(buffer, head)
if (start < 0) {
buffer = if (buffer.size > 3) buffer.copyOfRange(buffer.size - 3, buffer.size) else buffer
break
}
if (start > 0) {
buffer = buffer.copyOfRange(start, buffer.size)
}
if (buffer.size < 18) break
val dataLength = leUInt32(buffer, 8)
if (dataLength < 0 || dataLength > MAX_FRAME_DATA) {
buffer = buffer.copyOfRange(1, buffer.size)
continue
}
val total = 18 + dataLength
if (buffer.size < total) break
val tail = buffer.copyOfRange(total - 4, total)
if (!tail.contentEquals(end)) {
buffer = buffer.copyOfRange(1, buffer.size)
continue
}
val command = leUInt16(buffer, 4)
val index = leUInt16(buffer, 6)
val payload = if (dataLength > 0) {
buffer.copyOfRange(14, 14 + dataLength)
} else {
byteArrayOf()
}
val crcExpected = leUInt16(buffer, 12)
val crcActual = crc16(payload)
if (crcExpected == crcActual) {
out += HichipsFrame(command = command, index = index, payload = payload)
}
buffer = if (buffer.size == total) byteArrayOf() else buffer.copyOfRange(total, buffer.size)
}
return out
}
fun hasPendingFrame(): Boolean {
return buffer.isNotEmpty()
}
private fun leUInt16(bytes: ByteArray, offset: Int): Int {
return ((bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8))
}
private fun leUInt32(bytes: ByteArray, offset: Int): Int {
val b0 = bytes[offset].toInt() and 0xFF
val b1 = bytes[offset + 1].toInt() and 0xFF
val b2 = bytes[offset + 2].toInt() and 0xFF
val b3 = bytes[offset + 3].toInt() and 0xFF
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
}
private fun indexOf(source: ByteArray, target: ByteArray): Int {
if (target.isEmpty()) return 0
if (source.size < target.size) return -1
for (i in 0..(source.size - target.size)) {
var matched = true
for (j in target.indices) {
if (source[i + j] != target[j]) {
matched = false
break
}
}
if (matched) return i
}
return -1
}
private fun crc16(data: ByteArray): Int {
var crc = 0xFFFF
for (b in data) {
crc = ((crc ushr 8) or ((crc and 0xFF) shl 8)) and 0xFFFF
crc = crc xor (b.toInt() and 0xFF)
crc = crc xor ((crc and 0xFF) ushr 4)
crc = crc xor ((crc shl 8) shl 4)
crc = crc xor (((crc and 0xFF) shl 4) shl 1)
crc = crc and 0xFFFF
}
return crc and 0xFFFF
}
}
class BleManager(private val context: Context) {
private val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val adapter: BluetoothAdapter? = btManager.adapter
private val _state = MutableStateFlow(BleLinkState())
val state: StateFlow<BleLinkState> = _state.asStateFlow()
private val _events = MutableSharedFlow<GlassesBleEvent>(extraBufferCapacity = 256)
val events: SharedFlow<GlassesBleEvent> = _events.asSharedFlow()
private var gatt: BluetoothGatt? = null
private var scannerCallback: ScanCallback? = null
private var pendingAppUuid: String = ""
private var waitingAsrStart = false
private var write3D23: BluetoothGattCharacteristic? = null
private var write5DC3: BluetoothGattCharacteristic? = null
private val notifyQueue = ArrayDeque<BluetoothGattCharacteristic>()
private val assembler3D21 = FrameAssembler()
private val assembler3D22 = FrameAssembler()
private val assembler5DC1 = FrameAssembler()
private val assembler5DC2 = FrameAssembler()
@SuppressLint("MissingPermission")
fun connectAndHandshake(appUuid: String, nameHint: String? = null) {
val bt = adapter
if (bt == null || !bt.isEnabled) {
updateError("Bluetooth not enabled")
return
}
pendingAppUuid = appUuid.take(32)
if (_state.value.connected) {
emitLog("BLE already connected, waiting for handshake packets")
return
}
stopScan()
_state.value = _state.value.copy(scanning = true, lastError = "")
val filters = listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(HichipsUuid.service3D20))
.build()
)
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
scannerCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device ?: return
val deviceName = runCatching { device.name.orEmpty() }.getOrDefault("")
if (nameHint.isNullOrBlank().not() && !deviceName.contains(nameHint!!, ignoreCase = true)) {
return
}
stopScan()
emitLog("BLE found ${device.address} ${deviceName.ifBlank { "(no-name)" }}")
connectDevice(device)
}
override fun onScanFailed(errorCode: Int) {
updateError("BLE scan failed: $errorCode")
}
}
bt.bluetoothLeScanner?.startScan(filters, settings, scannerCallback)
emitLog("BLE scanning...")
}
@SuppressLint("MissingPermission")
fun disconnect() {
stopScan()
runCatching { gatt?.disconnect() }
runCatching { gatt?.close() }
gatt = null
_state.value = BleLinkState()
}
fun startWakeUpAudio() {
waitingAsrStart = true
val ok = sendAsrCommand(HichipsCmd.ASR_APP_WAKE_UP, null)
emitLog(if (ok) "ASR wake-up command sent" else "ASR wake-up send failed")
}
fun stopWakeUpAudio() {
waitingAsrStart = false
val ok = sendAsrCommand(HichipsCmd.ASR_TRANS_APP_SET_STOP, null)
emitLog(if (ok) "ASR stop command sent" else "ASR stop send failed")
}
fun triggerPhotoCapture() {
val ok = sendAgCommand(HichipsCmd.AG_P_TAKE_START, null)
emitLog(if (ok) "Photo capture command sent" else "Photo capture send failed")
}
fun requestAllStatus() {
sendAgCommand(HichipsCmd.AG_GET_ALL_STATUS, null)
}
@SuppressLint("MissingPermission")
private fun connectDevice(device: BluetoothDevice) {
runCatching { gatt?.close() }
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
_state.value = _state.value.copy(
scanning = false,
connected = false,
notificationsReady = false,
handshaked = false,
deviceAddress = device.address,
deviceName = runCatching { device.name.orEmpty() }.getOrDefault("")
)
}
@SuppressLint("MissingPermission")
private fun stopScan() {
scannerCallback?.let { cb ->
adapter?.bluetoothLeScanner?.stopScan(cb)
}
scannerCallback = null
_state.value = _state.value.copy(scanning = false)
}
private val callback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
updateError("BLE connect error status=$status")
return
}
if (newState == BluetoothProfile.STATE_CONNECTED) {
_state.value = _state.value.copy(connected = true, lastError = "")
emitLog("BLE connected, discovering services")
gatt.requestMtu(247)
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
_state.value = _state.value.copy(
connected = false,
notificationsReady = false,
handshaked = false
)
emitLog("BLE disconnected")
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
emitLog("BLE mtu=$mtu status=$status")
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
updateError("Service discovery failed: $status")
return
}
bindCharacteristics(gatt)
startEnableNotifications()
}
override fun onDescriptorWrite(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int
) {
if (status != BluetoothGatt.GATT_SUCCESS) {
updateError("Descriptor write failed: $status")
return
}
writeNextNotificationDescriptor()
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
handleCharacteristicChanged(characteristic.uuid, characteristic.value ?: byteArrayOf())
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
handleCharacteristicChanged(characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun bindCharacteristics(gatt: BluetoothGatt) {
val s3 = gatt.getService(HichipsUuid.service3D20)
val s5 = gatt.getService(HichipsUuid.service5DC0)
write3D23 = s3?.getCharacteristic(HichipsUuid.char3D23Write)
write5DC3 = s5?.getCharacteristic(HichipsUuid.char5DC3Write)
}
private fun startEnableNotifications() {
val g = gatt ?: return
notifyQueue.clear()
enqueueNotify(g, HichipsUuid.service3D20, HichipsUuid.char3D21Notify)
enqueueNotify(g, HichipsUuid.service3D20, HichipsUuid.char3D22NotifyData)
enqueueNotify(g, HichipsUuid.service5DC0, HichipsUuid.char5DC1Notify)
enqueueNotify(g, HichipsUuid.service5DC0, HichipsUuid.char5DC2NotifyData)
writeNextNotificationDescriptor()
}
private fun enqueueNotify(gatt: BluetoothGatt, serviceUuid: UUID, charUuid: UUID) {
val characteristic = gatt.getService(serviceUuid)?.getCharacteristic(charUuid) ?: return
notifyQueue.add(characteristic)
}
@SuppressLint("MissingPermission")
private fun writeNextNotificationDescriptor() {
val g = gatt ?: return
if (notifyQueue.isEmpty()) {
_state.value = _state.value.copy(notificationsReady = true)
emitLog("BLE notifications enabled")
return
}
val c = notifyQueue.removeFirst()
g.setCharacteristicNotification(c, true)
val descriptor = c.getDescriptor(HichipsUuid.cccd) ?: run {
writeNextNotificationDescriptor()
return
}
val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = g.writeDescriptor(descriptor, value)
if (result != BluetoothStatusCodes.SUCCESS) {
updateError("writeDescriptor failed: $result")
}
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = value
val ok = g.writeDescriptor(descriptor)
if (!ok) updateError("writeDescriptor returned false")
}
}
}
private fun handleCharacteristicChanged(uuid: UUID, value: ByteArray) {
if (value.isEmpty()) return
when (uuid) {
HichipsUuid.char3D21Notify -> decodeAndDispatchFrames(value, assembler3D21, isWakeChannel = false, isDataChannel = false)
HichipsUuid.char5DC1Notify -> decodeAndDispatchFrames(value, assembler5DC1, isWakeChannel = true, isDataChannel = false)
HichipsUuid.char3D22NotifyData -> decodeAndDispatchFrames(value, assembler3D22, isWakeChannel = false, isDataChannel = true)
HichipsUuid.char5DC2NotifyData -> decodeAndDispatchFrames(value, assembler5DC2, isWakeChannel = true, isDataChannel = true)
}
}
private fun decodeAndDispatchFrames(
value: ByteArray,
assembler: FrameAssembler,
isWakeChannel: Boolean,
isDataChannel: Boolean
) {
val isPacketized = value.size >= 4 &&
value[0] == 0x48.toByte() &&
value[1] == 0x49.toByte() &&
value[2] == 0x43.toByte() &&
value[3] == 0x48.toByte()
if (isDataChannel && !isPacketized && !assembler.hasPendingFrame()) {
if (isWakeChannel) {
_events.tryEmit(GlassesBleEvent.AudioFrame(bytes = value, index = 0))
}
return
}
val frames = assembler.append(value)
for (frame in frames) {
onFrame(frame, isWakeChannel, isDataChannel)
}
}
private fun onFrame(frame: HichipsFrame, isWakeChannel: Boolean, isDataChannel: Boolean) {
if (isWakeChannel && isDataChannel && frame.command == HichipsCmd.ASR_TRANS_AUDIO) {
_events.tryEmit(GlassesBleEvent.AudioFrame(bytes = frame.payload, index = frame.index))
return
}
if (!isWakeChannel && isDataChannel) {
when (frame.command) {
HichipsCmd.AG_P_THUMB_DATA -> _events.tryEmit(
GlassesBleEvent.CameraThumbData(
bytes = frame.payload,
index = frame.index,
isVideo = false
)
)
HichipsCmd.AG_V_THUMB_DATA -> _events.tryEmit(
GlassesBleEvent.CameraThumbData(
bytes = frame.payload,
index = frame.index,
isVideo = true
)
)
}
return
}
if (isWakeChannel) {
when (frame.command) {
HichipsCmd.ASR_DEV_WAKE_UP -> {
emitLog("Device wake-up received")
if (waitingAsrStart) {
val setting = JSONObject()
.put("FlowCtrl", 0)
.put("LengthByte", 80)
.put("IntervalMs", 20)
.put("Packag", 1)
sendAsrCommand(HichipsCmd.ASR_TRANS_SETTING, setting.toString())
}
}
HichipsCmd.ASR_TRANS_START -> emitLog("ASR trans start")
HichipsCmd.ASR_TRANS_STOP -> emitLog("ASR trans stop")
}
return
}
when (frame.command) {
HichipsCmd.AG_HS_DEV_UUID -> {
val json = parseJson(frame.payload)
val devUuid = json?.optString("DevUuid", "").orEmpty()
if (devUuid.isNotBlank()) {
_state.value = _state.value.copy(devUuid = devUuid)
val appUuidPayload = JSONObject()
.put("Time", System.currentTimeMillis() / 1000L)
.put("AppUuid", pendingAppUuid.take(32))
.toString()
sendAgCommand(HichipsCmd.AG_HS_APP_UUID, appUuidPayload)
emitLog("Handshake step2 done, app uuid sent")
}
}
HichipsCmd.AG_HS_DEV_INFO -> {
val json = parseJson(frame.payload)
val fail = json?.optString("Status") == "Fail"
if (fail) {
updateError("Handshake rejected: ${json?.optInt("ErrorCode", -1)}")
return
}
val devUuid = json?.optString("DevUuid", _state.value.devUuid).orEmpty()
val devName = json?.optString("DevName", "").orEmpty()
val fw = json?.optString("DevFwVer", "").orEmpty()
_state.value = _state.value.copy(
handshaked = true,
devUuid = devUuid.ifBlank { _state.value.devUuid },
deviceName = devName.ifBlank { _state.value.deviceName }
)
_events.tryEmit(GlassesBleEvent.HandshakeOk(devUuid = _state.value.devUuid, devName = devName, devFwVer = fw))
emitLog("Handshake completed")
}
HichipsCmd.AG_GET_ALL_STATUS -> {
val jsonText = frame.payload.decodeToString()
_events.tryEmit(GlassesBleEvent.StatusUpdate(jsonText))
}
HichipsCmd.AG_P_THUMB_INFO -> {
val source = parseJson(frame.payload)?.optString("SourceFileName", "").orEmpty()
_events.tryEmit(GlassesBleEvent.CameraThumbInfo(sourceFileName = source, isVideo = false))
}
HichipsCmd.AG_V_THUMB_INFO -> {
val source = parseJson(frame.payload)?.optString("SourceFileName", "").orEmpty()
_events.tryEmit(GlassesBleEvent.CameraThumbInfo(sourceFileName = source, isVideo = true))
}
}
}
private fun parseJson(bytes: ByteArray): JSONObject? {
if (bytes.isEmpty()) return null
return runCatching {
JSONObject(bytes.decodeToString())
}.getOrNull()
}
private fun sendAgCommand(command: Int, jsonPayload: String?): Boolean {
val payload = jsonPayload?.toByteArray(Charsets.UTF_8) ?: byteArrayOf()
return writeFrame(write3D23, command, payload)
}
private fun sendAsrCommand(command: Int, jsonPayload: String?): Boolean {
val payload = jsonPayload?.toByteArray(Charsets.UTF_8) ?: byteArrayOf()
return writeFrame(write5DC3, command, payload)
}
@SuppressLint("MissingPermission")
private fun writeFrame(
characteristic: BluetoothGattCharacteristic?,
command: Int,
payload: ByteArray
): Boolean {
val g = gatt ?: return false
val c = characteristic ?: return false
val frame = buildFrame(command = command, index = 0, payload = payload)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(c, frame, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) ==
BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
c.value = frame
g.writeCharacteristic(c)
}
}
}
private fun buildFrame(command: Int, index: Int, payload: ByteArray): ByteArray {
val buffer = ByteBuffer.allocate(18 + payload.size).order(ByteOrder.LITTLE_ENDIAN)
buffer.put(byteArrayOf(0x48, 0x49, 0x43, 0x48)) // HICH
buffer.putShort(command.toShort())
buffer.putShort(index.toShort())
buffer.putInt(payload.size)
buffer.putShort(crc16(payload).toShort())
if (payload.isNotEmpty()) buffer.put(payload)
buffer.put(byteArrayOf(0x49, 0x50, 0x53, 0x45)) // IPSE
return buffer.array()
}
private fun crc16(data: ByteArray): Int {
var crc = 0xFFFF
for (b in data) {
crc = ((crc ushr 8) or ((crc and 0xFF) shl 8)) and 0xFFFF
crc = crc xor (b.toInt() and 0xFF)
crc = crc xor ((crc and 0xFF) ushr 4)
crc = crc xor ((crc shl 8) shl 4)
crc = crc xor (((crc and 0xFF) shl 4) shl 1)
crc = crc and 0xFFFF
}
return crc and 0xFFFF
}
private fun emitLog(message: String) {
_events.tryEmit(GlassesBleEvent.Log(message))
}
private fun updateError(message: String) {
_state.value = _state.value.copy(lastError = message)
_events.tryEmit(GlassesBleEvent.Log("ERROR: $message"))
}
}

View File

@@ -1,51 +0,0 @@
package com.aiglasses.app.data
import com.aiglasses.app.BuildConfig
import kotlinx.serialization.json.Json
import kotlinx.serialization.ExperimentalSerializationApi
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
object ApiClient {
@OptIn(ExperimentalSerializationApi::class)
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
inline fun <reified T : Any> createService(baseUrl: String): T {
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(12, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.callTimeout(25, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request: Request = chain.request().newBuilder()
.header("Connection", "close")
.build()
chain.proceed(request)
}
.addInterceptor(logging)
.build()
val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
return Retrofit.Builder()
.baseUrl(normalizedBaseUrl)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create<T>()
}
}

View File

@@ -1,154 +0,0 @@
package com.aiglasses.app.data
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
@GET("/healthz")
suspend fun healthz(): ApiEnvelope<HealthzData>
@POST("/api/v1/devices/bind-confirm")
suspend fun bindConfirm(
@Body request: BindConfirmRequest
): ApiEnvelope<BindConfirmData>
@POST("/api/v1/ai/sessions")
suspend fun createSession(
@Header("Idempotency-Key") idempotencyKey: String?,
@Body request: CreateSessionRequest
): ApiEnvelope<SessionData>
@POST("/api/v1/ai/sessions/{sessionId}/stop")
suspend fun stopSession(
@Path("sessionId") sessionId: String,
@Body request: StopSessionRequest
): ApiEnvelope<StopSessionData>
@POST("/api/v1/ai/sessions/{sessionId}/heartbeat")
suspend fun heartbeat(
@Path("sessionId") sessionId: String,
@Body request: HeartbeatRequest
): ApiEnvelope<HeartbeatData>
@GET("/api/v1/devices/{deviceId}/status")
suspend fun getDeviceStatus(
@Path("deviceId") deviceId: String
): ApiEnvelope<DeviceStatusData>
@POST("/api/v1/events")
suspend fun postEvent(
@Body request: ClientEventRequest
): ApiEnvelope<EventSavedData>
@POST("/api/v1/events/batch")
suspend fun postEventsBatch(
@Body request: ClientEventBatchRequest
): ApiEnvelope<EventsBatchSavedData>
@POST("/api/v1/ai/sessions/{sessionId}/messages")
suspend fun sendMessage(
@Path("sessionId") sessionId: String,
@Body request: SessionMessageRequest
): ApiEnvelope<ProviderActionData>
@POST("/api/v1/ai/sessions/{sessionId}/scene-role")
suspend fun switchRole(
@Path("sessionId") sessionId: String,
@Body request: SwitchRoleRequest
): ApiEnvelope<ProviderActionData>
@POST("/api/v1/ai/sessions/{sessionId}/interrupt")
suspend fun interruptSession(
@Path("sessionId") sessionId: String,
@Body request: SessionInterruptRequest
): ApiEnvelope<ProviderActionData>
@GET("/api/v1/baidu/activation/query")
suspend fun activationQuery(
@Query("deviceId") deviceId: String,
@Query("appId") appId: String? = null
): ApiEnvelope<ActivationQueryData>
@POST("/api/v1/licenses/reload")
suspend fun reloadLicenses(): ApiEnvelope<ReloadLicensesData>
@GET("/api/v1/admin/overview")
suspend fun adminOverview(): ApiEnvelope<AdminOverviewData>
@GET("/api/v1/app/update/latest")
suspend fun appUpdateLatest(
@Query("platform") platform: String = "android",
@Query("channel") channel: String = "stable",
@Query("currentVersionCode") currentVersionCode: Int
): ApiEnvelope<AppUpdateLatestData>
@GET("/v2/douyin/accounts")
suspend fun listDouyinAccounts(): ApiEnvelope<List<DouyinAccountSummary>>
@POST("/v2/douyin/accounts/sync")
suspend fun syncDouyinAccount(
@Body request: DouyinAccountSyncRequest
): ApiEnvelope<DouyinAccountWorkspace>
@GET("/v2/douyin/accounts/{accountId}")
suspend fun getDouyinAccount(
@Path("accountId") accountId: String
): ApiEnvelope<DouyinAccountWorkspace>
@GET("/v2/douyin/accounts/{accountId}/workspace")
suspend fun getDouyinWorkspace(
@Path("accountId") accountId: String
): ApiEnvelope<DouyinAccountWorkspace>
@GET("/v2/douyin/accounts/{accountId}/snapshots")
suspend fun listDouyinSnapshots(
@Path("accountId") accountId: String
): ApiEnvelope<List<DouyinSnapshotSummary>>
@GET("/v2/douyin/accounts/{accountId}/snapshots/{snapshotId}")
suspend fun getDouyinSnapshot(
@Path("accountId") accountId: String,
@Path("snapshotId") snapshotId: String
): ApiEnvelope<DouyinSnapshotDetail>
@GET("/v2/douyin/accounts/{accountId}/creator-fields")
suspend fun getDouyinCreatorFields(
@Path("accountId") accountId: String
): ApiEnvelope<DouyinSnapshotDetail>
@POST("/v2/douyin/accounts/{accountId}/analysis")
suspend fun analyzeDouyinAccount(
@Path("accountId") accountId: String,
@Body request: DouyinAccountAnalysisRequest
): ApiEnvelope<DouyinAnalysisResult>
@GET("/v2/douyin/accounts/{accountId}/analysis-reports")
suspend fun listDouyinAnalysisReports(
@Path("accountId") accountId: String
): ApiEnvelope<List<DouyinAnalysisReport>>
@POST("/v2/douyin/similar-searches")
suspend fun createDouyinSimilarSearch(
@Body request: DouyinSimilarSearchRequest
): ApiEnvelope<DouyinSimilaritySearchResult>
@GET("/v2/douyin/similar-searches/{searchId}")
suspend fun getDouyinSimilarSearch(
@Path("searchId") searchId: String
): ApiEnvelope<DouyinSimilaritySearchDetail>
@GET("/v2/douyin/accounts/{accountId}/benchmark-links")
suspend fun listDouyinBenchmarkLinks(
@Path("accountId") accountId: String
): ApiEnvelope<List<DouyinLinkedAccount>>
@POST("/v2/douyin/accounts/{accountId}/benchmark-links")
suspend fun createDouyinBenchmarkLinks(
@Path("accountId") accountId: String,
@Body request: DouyinBenchmarkLinkRequest
): ApiEnvelope<DouyinBenchmarkLinkResult>
}

View File

@@ -1,276 +0,0 @@
package com.aiglasses.app.data
import java.util.UUID
class BackendRepository(private var baseUrl: String) {
private var api: ApiService = ApiClient.createService(baseUrl)
fun updateBaseUrl(url: String) {
if (url != baseUrl) {
baseUrl = url
api = ApiClient.createService(baseUrl)
}
}
suspend fun bindDevice(deviceId: String, userId: String): BindConfirmData {
val resp = api.bindConfirm(BindConfirmRequest(deviceId = deviceId, appUserId = userId))
return resp.data
}
suspend fun healthz(): HealthzData {
val resp = api.healthz()
return resp.data
}
suspend fun createSession(deviceId: String, userId: String): SessionData {
val idempotencyKey = "app-${UUID.randomUUID()}"
val resp = api.createSession(
idempotencyKey = idempotencyKey,
request = CreateSessionRequest(deviceId = deviceId, appUserId = userId)
)
return resp.data
}
suspend fun stopSession(sessionId: String): StopSessionData {
val resp = api.stopSession(sessionId, StopSessionRequest())
return resp.data
}
suspend fun heartbeat(sessionId: String): HeartbeatData {
val resp = api.heartbeat(sessionId, HeartbeatRequest())
return resp.data
}
suspend fun getDeviceStatus(deviceId: String): DeviceStatusData {
val resp = api.getDeviceStatus(deviceId)
return resp.data
}
suspend fun postDemoEvent(deviceId: String, sessionId: String?): EventSavedData {
return postEvent(
deviceId = deviceId,
sessionId = sessionId,
eventType = "APP_DEBUG_PING",
eventLevel = "INFO",
payload = mapOf("source" to "android")
)
}
suspend fun postEvent(
deviceId: String,
sessionId: String?,
eventType: String,
eventLevel: String = "INFO",
payload: Map<String, String> = emptyMap()
): EventSavedData {
val resp = api.postEvent(
ClientEventRequest(
sessionId = sessionId,
deviceId = deviceId,
eventType = eventType,
eventLevel = eventLevel,
payload = payload
)
)
return resp.data
}
suspend fun postEventsBatch(events: List<ClientEventRequest>): EventsBatchSavedData {
val resp = api.postEventsBatch(ClientEventBatchRequest(events = events))
return resp.data
}
suspend fun sendMessage(sessionId: String, message: String): ProviderActionData {
val resp = api.sendMessage(
sessionId = sessionId,
request = SessionMessageRequest(message = message)
)
return resp.data
}
suspend fun sendVoiceMessage(
sessionId: String,
pcmBase64: String,
sampleRate: Int,
durationMs: Int,
rms: Int
): ProviderActionData {
val resp = api.sendMessage(
sessionId = sessionId,
request = SessionMessageRequest(
message = "voice_chunk",
messageType = "voice",
extra = mapOf(
"audio_base64" to pcmBase64,
"audio_format" to "pcm_s16le",
"sample_rate" to sampleRate.toString(),
"channels" to "1",
"duration_ms" to durationMs.toString(),
"rms" to rms.toString(),
"encoding" to "base64"
)
)
)
return resp.data
}
suspend fun sendVisionMessage(
sessionId: String,
message: String,
imageBase64: String,
width: Int,
height: Int,
bytes: Int
): ProviderActionData {
val resp = api.sendMessage(
sessionId = sessionId,
request = SessionMessageRequest(
message = message,
messageType = "text",
extra = mapOf(
"image_base64" to imageBase64,
"imageBase64" to imageBase64,
"image" to imageBase64,
"resource_base64" to imageBase64,
"resourceBase64" to imageBase64,
"image_encoding" to "base64",
"imageEncoding" to "base64",
"encoding" to "base64",
"image_format" to "jpeg",
"imageFormat" to "jpeg",
"mime_type" to "image/jpeg",
"mimeType" to "image/jpeg",
"image_width" to width.toString(),
"imageWidth" to width.toString(),
"image_height" to height.toString(),
"imageHeight" to height.toString(),
"image_bytes" to bytes.toString(),
"imageBytes" to bytes.toString(),
"resource_type" to "image",
"resourceType" to "image",
"camera_source" to "android_phone",
"multimodal" to "true",
"with_vision" to "1"
)
)
)
return resp.data
}
suspend fun switchRole(sessionId: String, sceneId: String, roleId: String): ProviderActionData {
val resp = api.switchRole(
sessionId = sessionId,
request = SwitchRoleRequest(sceneId = sceneId, roleId = roleId)
)
return resp.data
}
suspend fun interrupt(
sessionId: String,
interrupt: Boolean,
extra: Map<String, String> = emptyMap()
): ProviderActionData {
val resp = api.interruptSession(
sessionId = sessionId,
request = SessionInterruptRequest(interrupt = interrupt, extra = extra)
)
return resp.data
}
suspend fun activationQuery(deviceId: String): ActivationQueryData {
val resp = api.activationQuery(deviceId = deviceId)
return resp.data
}
suspend fun reloadLicenses(): ReloadLicensesData {
val resp = api.reloadLicenses()
return resp.data
}
suspend fun adminOverview(): AdminOverviewData {
val resp = api.adminOverview()
return resp.data
}
suspend fun appUpdateLatest(currentVersionCode: Int): AppUpdateLatestData {
val resp = api.appUpdateLatest(
platform = "android",
channel = "stable",
currentVersionCode = currentVersionCode
)
return resp.data
}
suspend fun listDouyinAccounts(): List<DouyinAccountSummary> {
val resp = api.listDouyinAccounts()
return resp.data
}
suspend fun syncDouyinAccount(request: DouyinAccountSyncRequest): DouyinAccountWorkspace {
val resp = api.syncDouyinAccount(request)
return resp.data
}
suspend fun getDouyinAccount(accountId: String): DouyinAccountWorkspace {
val resp = api.getDouyinAccount(accountId)
return resp.data
}
suspend fun getDouyinWorkspace(accountId: String): DouyinAccountWorkspace {
val resp = api.getDouyinWorkspace(accountId)
return resp.data
}
suspend fun listDouyinSnapshots(accountId: String): List<DouyinSnapshotSummary> {
val resp = api.listDouyinSnapshots(accountId)
return resp.data
}
suspend fun getDouyinSnapshot(accountId: String, snapshotId: String): DouyinSnapshotDetail {
val resp = api.getDouyinSnapshot(accountId, snapshotId)
return resp.data
}
suspend fun getDouyinCreatorFields(accountId: String): DouyinSnapshotDetail {
val resp = api.getDouyinCreatorFields(accountId)
return resp.data
}
suspend fun analyzeDouyinAccount(
accountId: String,
request: DouyinAccountAnalysisRequest
): DouyinAnalysisResult {
val resp = api.analyzeDouyinAccount(accountId, request)
return resp.data
}
suspend fun listDouyinAnalysisReports(accountId: String): List<DouyinAnalysisReport> {
val resp = api.listDouyinAnalysisReports(accountId)
return resp.data
}
suspend fun createDouyinSimilarSearch(
request: DouyinSimilarSearchRequest
): DouyinSimilaritySearchResult {
val resp = api.createDouyinSimilarSearch(request)
return resp.data
}
suspend fun getDouyinSimilarSearch(searchId: String): DouyinSimilaritySearchDetail {
val resp = api.getDouyinSimilarSearch(searchId)
return resp.data
}
suspend fun listDouyinBenchmarkLinks(accountId: String): List<DouyinLinkedAccount> {
val resp = api.listDouyinBenchmarkLinks(accountId)
return resp.data
}
suspend fun createDouyinBenchmarkLinks(
accountId: String,
request: DouyinBenchmarkLinkRequest
): DouyinBenchmarkLinkResult {
val resp = api.createDouyinBenchmarkLinks(accountId, request)
return resp.data
}
}

View File

@@ -1,540 +0,0 @@
package com.aiglasses.app.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class ApiEnvelope<T>(
val code: Int,
val message: String,
val traceId: String,
val data: T
)
@Serializable
data class HealthzData(
val status: String = "",
val env: String = "",
val dbPath: String = ""
)
@Serializable
data class BindConfirmRequest(
val deviceId: String,
val deviceSn: String? = null,
val deviceModel: String? = null,
val deviceFwVer: String? = null,
val appUserId: String
)
@Serializable
data class BindConfirmData(
val bindStatus: String,
val licenseStatus: String,
val licenseKeyMasked: String,
val licenseKey: String = ""
)
@Serializable
data class CreateSessionRequest(
val deviceId: String,
val appUserId: String,
val scene: String = "voice_assistant",
val language: String = "zh-CN",
val clientTs: Long? = null
)
@Serializable
data class SessionData(
val sessionId: String,
val provider: String,
val cid: String,
val token: String,
val tokenExpireAt: Long,
val wsUrl: String,
val heartbeatSec: Int,
val appId: String = "",
val context: String = "",
val realtimeWsUrl: String = ""
)
@Serializable
data class StopSessionRequest(
val reason: String = "user_stop"
)
@Serializable
data class StopSessionData(
val sessionStatus: String
)
@Serializable
data class HeartbeatRequest(
val networkType: String? = "wifi",
val bleRssi: Int? = null
)
@Serializable
data class HeartbeatData(
val sessionStatus: String,
val heartbeatAt: Long
)
@Serializable
data class ClientEventRequest(
val sessionId: String? = null,
val deviceId: String,
val eventType: String,
val eventLevel: String = "INFO",
val payload: Map<String, String> = emptyMap(),
val ts: Long? = null
)
@Serializable
data class ClientEventBatchRequest(
val events: List<ClientEventRequest> = emptyList()
)
@Serializable
data class EventSavedData(
val saved: Boolean
)
@Serializable
data class EventsBatchSavedData(
val saved: Int = 0
)
@Serializable
data class SessionMessageRequest(
val message: String,
val messageType: String = "text",
val messageId: String? = null,
val extra: Map<String, String> = emptyMap()
)
@Serializable
data class ProviderActionData(
val status: String = "UNKNOWN",
val detail: String = "",
val asrText: String = "",
val ttsText: String = "",
val audioBase64: String = "",
val audioUrl: String = ""
)
@Serializable
data class SwitchRoleRequest(
val sceneId: String,
val roleId: String,
val extra: Map<String, String> = emptyMap()
)
@Serializable
data class SessionInterruptRequest(
val interrupt: Boolean = true,
val extra: Map<String, String> = emptyMap()
)
@Serializable
data class DeviceStatusData(
val bindStatus: String,
val licenseStatus: String,
val activeSessionId: String? = null,
val activeSessionStatus: String? = null
)
@Serializable
data class AdminStats(
val totalDevices: Int = 0,
val totalSessions: Int = 0,
val runningSessions: Int = 0,
val totalLicenses: Int = 0,
val usedLicenseQuota: Int = 0
)
@Serializable
data class BaiduInfo(
val mode: String = "-",
val generateConfigured: Boolean = false,
val stopConfigured: Boolean = false,
val activationQueryConfigured: Boolean = false
)
@Serializable
data class AdminOverviewData(
val stats: AdminStats = AdminStats(),
val baidu: BaiduInfo = BaiduInfo()
)
@Serializable
data class ActivationQueryData(
val deviceId: String = "",
val appId: String = "",
val status: String = "UNKNOWN",
val detail: String = "",
val licenseKeyMasked: String = ""
)
@Serializable
data class ReloadLicensesData(
val inserted: Int = 0
)
@Serializable
data class AppUpdateLatestData(
val platform: String = "android",
val channel: String = "stable",
val hasUpdate: Boolean = false,
val latestVersionCode: Int = 0,
val latestVersionName: String = "",
val minSupportedCode: Int = 0,
val downloadUrl: String = "",
val apkSha256: String = "",
val releaseNotes: String = "",
val forceUpdate: Boolean = false,
val publishedAt: Long = 0L
)
@Serializable
data class DouyinManualPageCaptureRequest(
val url: String = "",
val title: String = "",
val payload: JsonObject = JsonObject(emptyMap())
)
@Serializable
data class DouyinAccountSyncRequest(
@SerialName("profile_url")
val profileUrl: String = "",
@SerialName("session_cookie")
val sessionCookie: String = "",
@SerialName("creator_center_urls")
val creatorCenterUrls: List<String> = emptyList(),
@SerialName("manual_profile_payload")
val manualProfilePayload: JsonObject? = null,
@SerialName("manual_creator_pages")
val manualCreatorPages: List<DouyinManualPageCaptureRequest> = emptyList(),
@SerialName("manual_work_payloads")
val manualWorkPayloads: List<JsonObject> = emptyList(),
@SerialName("discovery_note")
val discoveryNote: String = ""
)
@Serializable
data class DouyinProfileStats(
val followers: Double = 0.0,
val following: Double = 0.0,
val likes: Double = 0.0,
val videos: Double = 0.0
)
@Serializable
data class DouyinVideoStats(
val play: Double = 0.0,
val like: Double = 0.0,
val comment: Double = 0.0,
val share: Double = 0.0,
val collect: Double = 0.0
)
@Serializable
data class DouyinVideoSummaryItem(
@SerialName("aweme_id")
val awemeId: String = "",
val title: String = "",
val description: String = "",
val tags: List<String> = emptyList(),
@SerialName("published_at")
val publishedAt: String? = null,
val stats: DouyinVideoStats = DouyinVideoStats()
)
@Serializable
data class DouyinVideoSummary(
val count: Int = 0,
@SerialName("top_tags")
val topTags: List<String> = emptyList(),
@SerialName("avg_play")
val avgPlay: Double = 0.0,
@SerialName("avg_like")
val avgLike: Double = 0.0,
@SerialName("avg_comment")
val avgComment: Double = 0.0,
@SerialName("avg_share")
val avgShare: Double = 0.0,
val videos: List<DouyinVideoSummaryItem> = emptyList()
)
@Serializable
data class DouyinAccountSummary(
val id: String = "",
val nickname: String = "",
val signature: String = "",
@SerialName("profile_url")
val profileUrl: String = "",
@SerialName("avatar_url")
val avatarUrl: String = "",
@SerialName("sec_uid")
val secUid: String = "",
@SerialName("douyin_id")
val douyinId: String = "",
@SerialName("profile_stats")
val profileStats: DouyinProfileStats = DouyinProfileStats(),
val tags: List<String> = emptyList(),
val keywords: List<String> = emptyList(),
@SerialName("sync_status")
val syncStatus: String = "",
@SerialName("video_summary")
val videoSummary: DouyinVideoSummary = DouyinVideoSummary()
)
@Serializable
data class DouyinSnapshotSummary(
val id: String = "",
@SerialName("snapshot_type")
val snapshotType: String = "",
@SerialName("source_url")
val sourceUrl: String = "",
@SerialName("field_count")
val fieldCount: Int = 0,
@SerialName("collected_at")
val collectedAt: String = "",
val summary: JsonObject = JsonObject(emptyMap())
)
@Serializable
data class DouyinModelProfileSummary(
val id: String = "",
val name: String = "",
@SerialName("model_name")
val modelName: String = "",
@SerialName("base_url")
val baseUrl: String = "",
@SerialName("is_default")
val isDefault: Boolean = false
)
@Serializable
data class DouyinAnalysisSuggestion(
val id: String = "",
@SerialName("model_profile_id")
val modelProfileId: String = "",
@SerialName("model_label")
val modelLabel: String = "",
val status: String = "",
@SerialName("suggestion_text")
val suggestionText: String = "",
@SerialName("parsed_json")
val parsedJson: JsonElement = JsonObject(emptyMap())
)
@Serializable
data class DouyinAnalysisReport(
val id: String = "",
@SerialName("focus_text")
val focusText: String = "",
@SerialName("model_profile_ids")
val modelProfileIds: List<String> = emptyList(),
@SerialName("linked_account_ids")
val linkedAccountIds: List<String> = emptyList(),
@SerialName("created_at")
val createdAt: String = "",
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
)
@Serializable
data class DouyinSimilaritySearchPreview(
val id: String = "",
val keywords: List<String> = emptyList(),
@SerialName("created_at")
val createdAt: String = ""
)
@Serializable
data class DouyinLinkedAccount(
@SerialName("relation_id")
val relationId: String = "",
@SerialName("relation_type")
val relationType: String = "",
val note: String = "",
@SerialName("search_id")
val searchId: String = "",
@SerialName("created_at")
val createdAt: String = "",
@SerialName("target_account_id")
val targetAccountId: String? = null,
@SerialName("target_profile_url")
val targetProfileUrl: String = "",
@SerialName("target_nickname")
val targetNickname: String = "",
@SerialName("target_signature")
val targetSignature: String = "",
@SerialName("target_profile_stats")
val targetProfileStats: DouyinProfileStats = DouyinProfileStats(),
@SerialName("target_tags")
val targetTags: List<String> = emptyList()
)
@Serializable
data class DouyinAccountWorkspace(
val account: DouyinAccountSummary = DouyinAccountSummary(),
@SerialName("latest_public_snapshot")
val latestPublicSnapshot: DouyinSnapshotSummary? = null,
@SerialName("latest_creator_snapshot")
val latestCreatorSnapshot: DouyinSnapshotSummary? = null,
@SerialName("linked_accounts")
val linkedAccounts: List<DouyinLinkedAccount> = emptyList(),
@SerialName("recent_reports")
val recentReports: List<DouyinAnalysisReport> = emptyList(),
@SerialName("recent_similarity_searches")
val recentSimilaritySearches: List<DouyinSimilaritySearchPreview> = emptyList(),
@SerialName("available_model_profiles")
val availableModelProfiles: List<DouyinModelProfileSummary> = emptyList(),
@SerialName("sync_errors")
val syncErrors: List<String> = emptyList()
)
@Serializable
data class DouyinAccountAnalysisRequest(
@SerialName("model_profile_ids")
val modelProfileIds: List<String> = emptyList(),
@SerialName("linked_account_ids")
val linkedAccountIds: List<String> = emptyList(),
@SerialName("include_linked_accounts")
val includeLinkedAccounts: Boolean = true,
@SerialName("include_recent_similar_candidates")
val includeRecentSimilarCandidates: Boolean = true,
@SerialName("max_videos")
val maxVideos: Int = 12,
@SerialName("extra_focus")
val extraFocus: String = "",
val temperature: Double = 0.35
)
@Serializable
data class DouyinAnalysisResult(
@SerialName("report_id")
val reportId: String = "",
@SerialName("created_at")
val createdAt: String = "",
val context: JsonElement = JsonObject(emptyMap()),
val suggestions: List<DouyinAnalysisSuggestion> = emptyList()
)
@Serializable
data class DouyinSimilarSearchRequest(
@SerialName("source_account_id")
val sourceAccountId: String? = null,
@SerialName("profile_url")
val profileUrl: String? = null,
@SerialName("candidate_urls")
val candidateUrls: List<String> = emptyList(),
@SerialName("seed_linked_accounts")
val seedLinkedAccounts: Boolean = true,
@SerialName("search_public_pages")
val searchPublicPages: Boolean = true,
@SerialName("model_profile_id")
val modelProfileId: String? = null,
@SerialName("max_candidates")
val maxCandidates: Int = 10,
@SerialName("extra_requirements")
val extraRequirements: String = ""
)
@Serializable
data class DouyinSimilarCandidate(
val id: String = "",
@SerialName("candidate_account_id")
val candidateAccountId: String? = null,
@SerialName("candidate_profile_url")
val candidateProfileUrl: String = "",
@SerialName("candidate_nickname")
val candidateNickname: String = "",
@SerialName("heuristic_score")
val heuristicScore: Double = 0.0,
@SerialName("agent_score")
val agentScore: Double = 0.0,
@SerialName("rationale_text")
val rationaleText: String = "",
val dimensions: JsonElement = JsonObject(emptyMap()),
@SerialName("rank_index")
val rankIndex: Int = 0
)
@Serializable
data class DouyinSimilaritySearchResult(
@SerialName("search_id")
val searchId: String = "",
@SerialName("source_account")
val sourceAccount: DouyinAccountSummary = DouyinAccountSummary(),
@SerialName("model_profile")
val modelProfile: JsonObject = JsonObject(emptyMap()),
@SerialName("raw_model_output")
val rawModelOutput: String = "",
val candidates: List<DouyinSimilarCandidate> = emptyList()
)
@Serializable
data class DouyinSimilaritySearchDetail(
val id: String = "",
@SerialName("source_account_id")
val sourceAccountId: String? = null,
@SerialName("source_profile_url")
val sourceProfileUrl: String = "",
val keywords: List<String> = emptyList(),
val context: JsonElement = JsonObject(emptyMap()),
@SerialName("created_at")
val createdAt: String = "",
val candidates: List<DouyinSimilarCandidate> = emptyList()
)
@Serializable
data class DouyinBenchmarkLinkRequest(
@SerialName("target_account_ids")
val targetAccountIds: List<String> = emptyList(),
@SerialName("target_profile_urls")
val targetProfileUrls: List<String> = emptyList(),
@SerialName("relation_type")
val relationType: String = "benchmark",
val note: String = "",
@SerialName("search_id")
val searchId: String = ""
)
@Serializable
data class DouyinBenchmarkLinkResult(
val saved: Int = 0,
@SerialName("relation_ids")
val relationIds: List<String> = emptyList(),
val links: List<DouyinLinkedAccount> = emptyList()
)
@Serializable
data class DouyinSnapshotField(
@SerialName("field_path")
val fieldPath: String = "",
@SerialName("field_type")
val fieldType: String = "",
@SerialName("field_value_text")
val fieldValueText: String = ""
)
@Serializable
data class DouyinSnapshotDetail(
val id: String = "",
@SerialName("snapshot_type")
val snapshotType: String = "",
@SerialName("source_url")
val sourceUrl: String = "",
@SerialName("field_count")
val fieldCount: Int = 0,
@SerialName("collected_at")
val collectedAt: String = "",
val summary: JsonObject = JsonObject(emptyMap()),
@SerialName("raw_payload")
val rawPayload: JsonElement = JsonObject(emptyMap()),
val fields: List<DouyinSnapshotField> = emptyList()
)

View File

@@ -1,325 +0,0 @@
package com.aiglasses.app.software
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import com.baidu.rtc.agent.AIAgentEngine
import com.baidu.rtc.agent.AIAgentEngineCallback
import com.baidu.rtc.agent.Constants
import java.io.File
private const val BAIDU_AGENT_RECONNECT_DELAY_MS = 900L
private const val BAIDU_IMAGE_UPLOAD_EXPIRE_SECONDS = 0
class BaiduConversationAgent(
context: Context,
private val onLog: (String) -> Unit,
private val onCallReady: () -> Unit,
private val onCallEnded: (String) -> Unit,
private val onFinalAsr: (String) -> Unit,
private val onAgentText: (String) -> Unit,
private val onTtsStart: () -> Unit,
private val onTtsEnd: () -> Unit,
private val onPlaybackAudio: (pcm: ByteArray, sampleRate: Int, channelCount: Int) -> Unit,
private val onImageUploadRequest: () -> Unit,
) {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private var engine: AIAgentEngine? = null
private var session: SessionConfig? = null
private var running = false
private var callBegun = false
private var reconnectScheduled = false
private var stopRequested = false
private var pendingUploadFile: File? = null
private val callback = object : AIAgentEngineCallback() {
override fun onConnectionStateChange(state: Int) {
onLog("Baidu agent connection state=$state")
}
override fun onCallStateChange(state: Int) {
when (state) {
Constants.CallState.ON_CALL_BEGIN -> {
callBegun = true
onLog("Baidu agent call begin")
onCallReady()
flushPendingUpload()
}
Constants.CallState.ON_CALL_END -> {
callBegun = false
onLog("Baidu agent call ended")
onCallEnded("call_end")
if (running && !stopRequested) {
scheduleReconnect("call_end")
}
}
}
}
override fun onError(error: Int, msg: String?, bundle: Bundle?) {
onLog("Baidu agent error: code=$error, msg=${msg?.take(80) ?: "-"}")
onCallEnded("error:$error")
if (running && !stopRequested) {
restart("error:$error")
}
}
override fun onLicenseStatus(code: Int) {
onLog("Baidu agent license status=$code")
}
override fun onUserAsrSubtitle(text: String?, isFinal: Boolean) {
if (!isFinal) return
val normalized = sanitizeText(text.orEmpty())
if (normalized.isNotBlank()) {
onFinalAsr(normalized)
}
}
override fun onAIAgentSubtitle(text: String?, isFinal: Boolean) {
if (!isFinal) return
val normalized = sanitizeText(text.orEmpty())
if (normalized.isNotBlank()) {
onAgentText(normalized)
}
}
override fun onAIAgentAudioStateChange(newState: Int) {
when (newState) {
Constants.AIAgentAudioStateType.SPEAKING -> onTtsStart()
Constants.AIAgentAudioStateType.STOPPED -> onTtsEnd()
}
}
override fun onPlaybackAudioFrame(data: ByteArray?, sampleRate: Int, channelCount: Int) {
val frame = data ?: return
if (frame.isEmpty()) return
onPlaybackAudio(frame, sampleRate, channelCount)
}
override fun onAgentIntent(type: String?, bundle: Bundle?) {
if (type == Constants.AgentIntentType.IMAGE_UPLOAD) {
onImageUploadRequest()
}
}
override fun onUploadFileStatus(code: Int, msg: String?) {
onLog("Baidu visual upload status: code=$code, msg=${msg?.take(80) ?: "-"}")
}
override fun onMessage(message: String?) {
val text = sanitizeText(message.orEmpty())
if (text.isNotBlank()) {
onLog("Baidu agent message: ${text.take(120)}")
}
}
}
fun updateSession(
appId: String,
cid: String,
token: String,
contextJson: String,
deviceId: String,
appUserId: String,
licenseKey: String,
) {
val next = SessionConfig(
appId = appId.trim(),
cid = cid.trim(),
token = token.trim(),
contextJson = contextJson.trim(),
deviceId = deviceId.trim(),
appUserId = appUserId.trim(),
licenseKey = licenseKey.trim(),
)
val changed = next != session
session = next
if (running && changed) {
onLog("Baidu session updated, restarting agent")
restart("session_updated")
}
}
fun start() {
running = true
stopRequested = false
startIfReady()
}
fun stop() {
running = false
stopRequested = true
reconnectScheduled = false
mainHandler.removeCallbacksAndMessages(RECONNECT_TOKEN)
pendingUploadFile?.let { safeDelete(it) }
pendingUploadFile = null
destroyEngine()
}
fun isCallActive(): Boolean = callBegun
fun pushAudioFrame(pcm: ByteArray, sampleRate: Int, channelCount: Int) {
if (!callBegun || pcm.isEmpty()) return
runCatching {
engine?.pushAudioFrame(pcm, System.nanoTime(), sampleRate, channelCount)
}.onFailure {
onLog("Baidu audio push failed: ${it.message}")
}
}
fun interrupt() {
if (!callBegun) return
runCatching { engine?.interrupt() }
.onFailure { onLog("Baidu interrupt failed: ${it.message}") }
}
fun uploadJpeg(jpegBytes: ByteArray): Boolean {
val file = prepareUploadFile(jpegBytes) ?: return false
if (!callBegun) {
pendingUploadFile?.let { safeDelete(it) }
pendingUploadFile = file
onLog("Baidu visual upload queued: waiting call begin")
return true
}
return sendUploadFile(file)
}
private fun startIfReady() {
if (!running || engine != null) return
val cfg = session ?: run {
onLog("Baidu agent start pending: session missing")
return
}
if (cfg.appId.isBlank() || cfg.cid.isBlank() || cfg.token.isBlank()) {
onLog("Baidu agent start pending: missing appId/cid/token")
return
}
val cidLong = cfg.cid.toLongOrNull()
if (cidLong == null) {
onLog("Baidu agent start failed: cid not numeric")
return
}
val params = AIAgentEngine.AIAgentEngineParams().apply {
appId = cfg.appId
workflow = "voiceChat"
aiAgentInstanceId = cidLong
context = cfg.contextJson
verbose = true
enableExternalAudioInput = true
enableExternalAudioOutput = true
enableVoiceInterrupt = false
licenseKey = cfg.licenseKey
// SDK internal license activation sends devId=userId, so this must be the device identity.
userId = cfg.deviceId
}
val nextEngine = runCatching { AIAgentEngine.init(appContext, params) }
.onFailure { onLog("Baidu agent init failed: ${it.message}") }
.getOrNull() ?: return
engine = nextEngine
nextEngine.setCallback(callback)
onLog(
"Baidu agent calling: cid=${cfg.cid}, deviceId=${cfg.deviceId}, " +
"appUserId=${cfg.appUserId}, contextLen=${cfg.contextJson.length}"
)
runCatching {
nextEngine.call(cfg.token, cidLong)
nextEngine.switchToSpeaker(true)
}.onFailure {
onLog("Baidu agent call failed: ${it.message}")
destroyEngine()
scheduleReconnect("call_failed")
}
}
private fun restart(reason: String) {
destroyEngine()
scheduleReconnect(reason)
}
private fun scheduleReconnect(reason: String) {
if (!running || reconnectScheduled) return
reconnectScheduled = true
onLog("Baidu agent reconnect scheduled: $reason")
mainHandler.postAtTime(
{
reconnectScheduled = false
if (!running) return@postAtTime
startIfReady()
},
RECONNECT_TOKEN,
SystemClock.uptimeMillis() + BAIDU_AGENT_RECONNECT_DELAY_MS
)
}
private fun destroyEngine() {
val current = engine ?: run {
callBegun = false
return
}
engine = null
callBegun = false
runCatching { current.hangup() }
runCatching { current.destroy() }
}
private fun flushPendingUpload() {
val pending = pendingUploadFile ?: return
pendingUploadFile = null
sendUploadFile(pending)
}
private fun sendUploadFile(file: File): Boolean {
val current = engine ?: run {
safeDelete(file)
return false
}
val ok = runCatching { current.uploadFile(file.absolutePath, BAIDU_IMAGE_UPLOAD_EXPIRE_SECONDS) }
.onFailure { onLog("Baidu visual upload call failed: ${it.message}") }
.getOrDefault(false)
if (ok) {
onLog("Baidu visual upload sent: ${file.name}, bytes=${file.length()}")
mainHandler.postDelayed({ safeDelete(file) }, 60_000L)
} else {
safeDelete(file)
onLog("Baidu visual upload send failed")
}
return ok
}
private fun prepareUploadFile(jpegBytes: ByteArray): File? {
return runCatching {
val dir = File(appContext.cacheDir, "baidu_uploads").apply { mkdirs() }
File.createTempFile("vision_", ".jpg", dir).apply { writeBytes(jpegBytes) }
}.onFailure {
onLog("Baidu visual file prepare failed: ${it.message}")
}.getOrNull()
}
private fun sanitizeText(raw: String): String {
return raw.substringBefore("|||").trim()
}
private fun safeDelete(file: File) {
runCatching { file.delete() }
}
private data class SessionConfig(
val appId: String,
val cid: String,
val token: String,
val contextJson: String,
val deviceId: String,
val appUserId: String,
val licenseKey: String,
)
private companion object {
val RECONNECT_TOKEN = Any()
}
}

View File

@@ -1,98 +0,0 @@
package com.aiglasses.app.software
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
class BaiduRealtimeWsClient(
private val onLog: (String) -> Unit,
private val onOpen: () -> Unit,
private val onText: (String) -> Unit,
private val onBinary: (ByteArray) -> Unit,
private val onClosed: (reason: String, byClient: Boolean) -> Unit,
) {
private val client = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.pingInterval(20, TimeUnit.SECONDS)
.build()
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var closedByClient = false
fun connect(url: String) {
disconnect("reconnect")
closedByClient = false
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, listener)
}
fun disconnect(reason: String = "client_stop") {
closedByClient = true
val current = webSocket
webSocket = null
runCatching { current?.close(1000, reason) }
runCatching { current?.cancel() }
}
fun sendText(text: String): Boolean {
return runCatching { webSocket?.send(text) == true }
.onFailure { onLog("Realtime WS send text failed: ${it.message}") }
.getOrDefault(false)
}
fun sendBinary(bytes: ByteArray): Boolean {
if (bytes.isEmpty()) return false
return runCatching { webSocket?.send(bytes.toByteString()) == true }
.onFailure { onLog("Realtime WS send binary failed: ${it.message}") }
.getOrDefault(false)
}
fun release() {
disconnect("release")
runCatching { client.dispatcher.executorService.shutdown() }
runCatching { client.connectionPool.evictAll() }
}
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
this@BaiduRealtimeWsClient.webSocket = webSocket
onOpen()
}
override fun onMessage(webSocket: WebSocket, text: String) {
onText(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
onBinary(bytes.toByteArray())
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (this@BaiduRealtimeWsClient.webSocket === webSocket) {
this@BaiduRealtimeWsClient.webSocket = null
}
onClosed("closed:$code:${reason.ifBlank { "-" }}", closedByClient)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
runCatching { webSocket.close(code, reason) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (this@BaiduRealtimeWsClient.webSocket === webSocket) {
this@BaiduRealtimeWsClient.webSocket = null
}
val code = response?.code ?: -1
val message = t.message ?: response?.message ?: "unknown"
onClosed("failure:$code:$message", closedByClient)
}
}
}

View File

@@ -1,240 +0,0 @@
package com.aiglasses.app.software
import android.content.Context
import android.os.Bundle
import com.baidu.rtc.agent.AIAgentEngine
import com.baidu.rtc.agent.AIAgentEngineCallback
import com.baidu.rtc.agent.Constants
import java.io.File
private const val VISUAL_UPLOAD_EXPIRE_SECONDS = 0
private const val VISUAL_UPLOAD_KEEP_MS = 10 * 60 * 1000L
class BaiduVisualUploader(
context: Context,
private val onLog: (String) -> Unit
) {
private data class SessionConfig(
val appId: String,
val cid: String,
val token: String,
val userId: String,
val licenseKey: String
) {
fun isValid(): Boolean = appId.isNotBlank() && cid.isNotBlank() && token.isNotBlank()
fun key(): String = listOf(appId, cid, token, userId, licenseKey).joinToString("|")
}
private val appContext = context.applicationContext
private val uploadDir = File(appContext.cacheDir, "baidu_visual_uploads").apply { mkdirs() }
private var sessionConfig: SessionConfig? = null
private var startedKey = ""
private var engine: AIAgentEngine? = null
private var ready = false
private var activeUploadFile: File? = null
private var pendingUploadFile: File? = null
private val callback = object : AIAgentEngineCallback() {
override fun onCallStateChange(state: Int) {
when (state) {
Constants.CallState.ON_CALL_BEGIN -> {
ready = true
engine?.muteMic(true)
engine?.mutePlayback(true)
onLog("Baidu visual uploader ready")
flushPendingUpload()
}
Constants.CallState.ON_CALL_END -> {
ready = false
onLog("Baidu visual uploader call ended")
}
}
}
override fun onConnectionStateChange(state: Int) {
onLog("Baidu visual connection state=$state")
}
override fun onUploadFileStatus(code: Int, msg: String) {
onLog("Baidu visual upload status: code=$code, msg=${msg.take(80)}")
deleteFile(activeUploadFile)
activeUploadFile = null
}
override fun onLicenseStatus(code: Int) {
onLog("Baidu visual license status=$code")
}
override fun onAgentIntent(type: String, bundle: Bundle?) {
if (type == Constants.AgentIntentType.IMAGE_UPLOAD) {
onLog("Baidu visual agent intent: IMAGE_UPLOAD")
}
}
override fun onError(error: Int, msg: String?, bundle: Bundle?) {
onLog("Baidu visual uploader error: code=$error, msg=${msg ?: "-"}")
}
override fun onMessage(message: String?) {
if (!message.isNullOrBlank()) {
onLog("Baidu visual message: ${message.take(80)}")
}
}
}
fun updateSession(appId: String, cid: String, token: String, userId: String, licenseKey: String) {
val next = SessionConfig(
appId = appId.trim(),
cid = cid.trim(),
token = token.trim(),
userId = userId.trim(),
licenseKey = licenseKey.trim()
)
if (next == sessionConfig) return
sessionConfig = next
val key = next.key()
if (engine != null && startedKey.isNotBlank() && key != startedKey) {
onLog("Baidu visual uploader session changed, restarting")
stop()
}
}
fun start() {
ensureStarted()
}
fun stop() {
ready = false
startedKey = ""
runCatching { engine?.hangup() }
runCatching { engine?.destroy() }
engine = null
deleteFile(activeUploadFile)
activeUploadFile = null
deleteFile(pendingUploadFile)
pendingUploadFile = null
}
fun uploadJpeg(jpegBytes: ByteArray): Boolean {
if (jpegBytes.isEmpty()) return false
val cfg = sessionConfig
if (cfg == null || !cfg.isValid()) {
onLog("Baidu visual uploader skipped: missing appId/cid/token")
return false
}
cleanupStaleFiles()
val file = runCatching {
File(uploadDir, "visual_${System.currentTimeMillis()}.jpg").apply {
writeBytes(jpegBytes)
}
}.getOrElse {
onLog("Baidu visual file prepare failed: ${it.message}")
return false
}
if (!ensureStarted()) {
deleteFile(file)
return false
}
if (!ready) {
replacePendingUpload(file)
onLog("Baidu visual upload queued: waiting call begin")
return true
}
return sendUploadFile(file)
}
private fun ensureStarted(): Boolean {
val cfg = sessionConfig
if (cfg == null || !cfg.isValid()) return false
val key = cfg.key()
if (engine != null && startedKey == key) return true
val cidLong = cfg.cid.toLongOrNull()
if (cidLong == null) {
onLog("Baidu visual uploader skipped: cid not numeric")
return false
}
stop()
val params = AIAgentEngine.AIAgentEngineParams().apply {
appId = cfg.appId
workflow = "voiceChat"
context = ""
verbose = true
enableExternalAudioInput = true
enableExternalAudioOutput = true
licenseKey = cfg.licenseKey
userId = cfg.userId
}
val nextEngine = runCatching {
AIAgentEngine.init(appContext, params)
}.getOrElse {
onLog("Baidu visual uploader init failed: ${it.message}")
return false
}
engine = nextEngine
engine?.setCallback(callback)
ready = false
startedKey = key
onLog("Baidu visual uploader calling: cid=${cfg.cid}")
runCatching {
nextEngine.call(cfg.token, cidLong)
}.onFailure {
onLog("Baidu visual uploader call failed: ${it.message}")
stop()
return false
}
return true
}
private fun flushPendingUpload() {
val file = pendingUploadFile ?: return
pendingUploadFile = null
if (!sendUploadFile(file)) {
replacePendingUpload(file)
}
}
private fun sendUploadFile(file: File): Boolean {
val nextEngine = engine ?: return false
deleteFile(activeUploadFile)
activeUploadFile = file
val ok = runCatching {
nextEngine.uploadFile(file.absolutePath, VISUAL_UPLOAD_EXPIRE_SECONDS)
}.getOrElse {
onLog("Baidu visual upload call failed: ${it.message}")
false
}
if (ok) {
onLog("Baidu visual upload sent: ${file.name}, bytes=${file.length()}")
} else {
onLog("Baidu visual upload send failed")
deleteFile(activeUploadFile)
activeUploadFile = null
}
return ok
}
private fun replacePendingUpload(file: File) {
deleteFile(pendingUploadFile)
pendingUploadFile = file
}
private fun cleanupStaleFiles() {
val cutoff = System.currentTimeMillis() - VISUAL_UPLOAD_KEEP_MS
uploadDir.listFiles()?.forEach { file ->
if (file.lastModified() < cutoff) {
deleteFile(file)
}
}
}
private fun deleteFile(file: File?) {
if (file == null) return
runCatching {
if (file.exists()) {
file.delete()
}
}
}
}

View File

@@ -1,109 +0,0 @@
package com.aiglasses.app.storyforge
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
interface StoryForgeApiService {
@POST("v2/auth/register")
suspend fun register(@Body request: RegisterAccountRequest): AccountDto
@POST("v2/auth/login")
suspend fun login(@Body request: LoginRequest): AuthResponseDto
@POST("v2/auth/logout")
suspend fun logout(): Map<String, Boolean>
@GET("v2/me")
suspend fun me(): AccountDto
@GET("v2/me/dashboard")
suspend fun dashboard(): DashboardDto
@GET("v2/model-profiles")
suspend fun modelProfiles(): List<ModelProfileDto>
@POST("v2/model-profiles")
suspend fun createModelProfile(@Body request: ModelProfileRequest): ModelProfileDto
@POST("v2/me/preferences/analysis-model")
suspend fun setPreferredAnalysisModel(@Body request: PreferredModelRequest): AccountDto
@GET("v2/knowledge-bases")
suspend fun knowledgeBases(): List<KnowledgeBaseDto>
@POST("v2/knowledge-bases")
suspend fun createKnowledgeBase(@Body request: KnowledgeBaseCreateRequest): KnowledgeBaseDto
@GET("v2/knowledge-bases/{knowledgeBaseId}/documents")
suspend fun knowledgeDocuments(@Path("knowledgeBaseId") knowledgeBaseId: String): List<KnowledgeDocumentDto>
@GET("v2/explore/jobs")
suspend fun jobs(): List<JobDto>
@GET("v2/explore/jobs/{jobId}")
suspend fun job(@Path("jobId") jobId: String): JobDto
@POST("v2/explore/video-link")
suspend fun createVideoLinkJob(@Body request: ExploreVideoLinkRequest): JobDto
@POST("v2/explore/text")
suspend fun createTextJob(@Body request: ExploreTextRequest): JobDto
@POST("v2/pipelines/content-source-sync")
suspend fun createContentSourceSyncJob(@Body request: ContentSourceSyncRequest): JobDto
@Multipart
@POST("v2/explore/upload-video")
suspend fun uploadVideo(
@Part file: MultipartBody.Part,
@Part("title") title: RequestBody,
@Part("knowledge_base_id") knowledgeBaseId: RequestBody,
@Part("assistant_id") assistantId: RequestBody,
@Part("analysis_model_profile_id") analysisModelProfileId: RequestBody
): JobDto
@GET("v2/assistants")
suspend fun assistants(): List<AssistantDto>
@POST("v2/assistants")
suspend fun createAssistant(@Body request: AssistantCreateRequest): AssistantDto
@PATCH("v2/assistants/{assistantId}")
suspend fun updateAssistant(
@Path("assistantId") assistantId: String,
@Body request: AssistantUpdateRequest
): AssistantDto
@POST("v2/assistants/{assistantId}/generate")
suspend fun generateCopy(
@Path("assistantId") assistantId: String,
@Body request: GenerateCopyRequest
): GenerateCopyResponseDto
@GET("v2/admin/accounts/pending")
suspend fun pendingAccounts(): List<AccountDto>
@POST("v2/admin/accounts/{accountId}/approve")
suspend fun approveAccount(@Path("accountId") accountId: String): ApprovalDecisionDto
@POST("v2/admin/accounts/{accountId}/reject")
suspend fun rejectAccount(@Path("accountId") accountId: String): ApprovalDecisionDto
@GET("api/v1/app/update/latest")
suspend fun latestUpdate(
@Query("platform") platform: String = "android",
@Query("channel") channel: String = "stable",
@Query("currentVersionCode") currentVersionCode: Int? = null
): AppUpdateLatestDto
@POST("v2/admin/app/update/publish")
suspend fun publishAppUpdate(@Body request: PublishAppUpdateRequest): PublishAppUpdateResponseDto
}

View File

@@ -1,295 +0,0 @@
package com.aiglasses.app.storyforge
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
@Serializable
data class RegisterAccountRequest(
val username: String,
val password: String,
val display_name: String
)
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class AccountDto(
val id: String,
val username: String,
val display_name: String,
val role: String,
val approval_status: String,
val approved_by: String? = null,
val approved_at: String? = null,
val preferred_analysis_model_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class AuthResponseDto(
val token: String,
val account: AccountDto,
val default_external_base_url: String = ""
)
@Serializable
data class ModelProfileDto(
val id: String,
val owner_account_id: String? = null,
val name: String,
val provider: String,
val base_url: String,
val api_key_masked: String = "",
val model_name: String,
val is_system: Boolean = false,
val is_default: Boolean = false,
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class ModelProfileRequest(
val name: String,
val base_url: String,
val api_key: String,
val model_name: String,
val is_default: Boolean = false
)
@Serializable
data class PreferredModelRequest(
val model_profile_id: String
)
@Serializable
data class ProjectDto(
val id: String,
val user_id: String,
val name: String,
val description: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class KnowledgeBaseDto(
val id: String,
val user_id: String,
val project_id: String = "",
val name: String,
val description: String = "",
val sync_status: String = "pending",
val document_count: Int = 0,
val linked_assistant_count: Int = 0,
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class KnowledgeBaseCreateRequest(
val name: String,
val project_id: String = "",
val description: String = ""
)
@Serializable
data class AssistantDto(
val id: String,
val user_id: String,
val project_id: String = "",
val name: String,
val description: String = "",
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val config: JsonObject = buildJsonObject { },
val model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class AssistantCreateRequest(
val name: String,
val description: String = "",
val system_prompt: String = "",
val generation_goal: String = "",
val knowledge_base_ids: List<String> = emptyList(),
val project_id: String = "",
val model_profile_id: String = ""
)
@Serializable
data class AssistantUpdateRequest(
val name: String? = null,
val description: String? = null,
val system_prompt: String? = null,
val generation_goal: String? = null,
val knowledge_base_ids: List<String>? = null,
val project_id: String? = null,
val model_profile_id: String? = null
)
@Serializable
data class ExploreVideoLinkRequest(
val video_url: String,
val title: String? = null,
val project_id: String? = null,
val knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null,
val language: String = "auto"
)
@Serializable
data class ExploreTextRequest(
val title: String,
val content: String,
val project_id: String? = null,
val knowledge_base_id: String? = null,
val assistant_id: String? = null,
val analysis_model_profile_id: String? = null
)
@Serializable
data class ContentSourceSyncRequest(
val project_id: String = "",
val knowledge_base_id: String = "",
val assistant_id: String = "",
val content_source_id: String = "",
val platform: String = "",
val handle: String = "",
val source_url: String = "",
val title: String = "",
val analysis_model_profile_id: String = "",
val language: String = "auto",
val max_items: Int = 5,
val skip_existing: Boolean = true,
val auto_trigger_analysis: Boolean = true
)
@Serializable
data class JobDto(
val id: String,
val user_id: String,
val project_id: String = "",
val parent_job_id: String = "",
val assistant_id: String? = null,
val knowledge_base_id: String,
val content_source_id: String = "",
val source_type: String,
val line_type: String = "analysis",
val workflow_key: String = "",
val orchestrator: String = "n8n",
val provider_name: String = "",
val provider_task_id: String = "",
val source_url: String? = null,
val title: String,
val language: String,
val status: String,
val transcript_text: String = "",
val style_summary: String = "",
val upload_status: String = "pending",
val error: String = "",
val artifacts: JsonObject = buildJsonObject { },
val result: JsonObject = buildJsonObject { },
val analysis_model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class KnowledgeDocumentDto(
val id: String,
val knowledge_base_id: String,
val title: String,
val source_type: String,
val source_url: String = "",
val transcript_text: String = "",
val style_summary: String = "",
val combined_text: String = "",
val analysis: JsonObject = buildJsonObject { },
val storyboards: JsonArray = buildJsonArray { },
val source_artifacts: JsonObject = buildJsonObject { },
val analysis_model_profile_id: String = "",
val created_at: String = "",
val updated_at: String = ""
)
@Serializable
data class GenerateCopyRequest(
val brief: String,
val platform: String = "抖音",
val audience: String = "创业者",
val extra_requirements: String = "",
val knowledge_base_ids: List<String> = emptyList()
)
@Serializable
data class GenerateCopyResponseDto(
val assistant_id: String,
val knowledge_base_ids: List<String>,
val content: String,
val prompt_excerpt: String,
val used_documents: List<KnowledgeDocumentDto> = emptyList()
)
@Serializable
data class DashboardDto(
val account: AccountDto,
val projects: List<ProjectDto> = emptyList(),
val knowledge_bases: List<KnowledgeBaseDto> = emptyList(),
val assistants: List<AssistantDto> = emptyList(),
val recent_jobs: List<JobDto> = emptyList(),
val model_profiles: List<ModelProfileDto> = emptyList()
)
@Serializable
data class ApprovalDecisionDto(
val saved: Boolean,
val account: AccountDto
)
@Serializable
data class PublishAppUpdateRequest(
val platform: String = "android",
val channel: String = "stable",
val versionCode: Int,
val versionName: String,
val minSupportedCode: Int,
val apkUrl: String,
val apkSha256: String = "",
val notes: String = "",
val forceUpdate: Boolean = false,
val isActive: Boolean = true
)
@Serializable
data class PublishAppUpdateResponseDto(
val saved: Boolean,
val action: String,
val updateId: Int = 0
)
@Serializable
data class AppUpdateLatestDto(
val platform: String = "android",
val channel: String = "stable",
val hasUpdate: Boolean = false,
val latestVersionCode: Int = 0,
val latestVersionName: String = "",
val minSupportedCode: Int = 0,
val downloadUrl: String = "",
val apkSha256: String = "",
val releaseNotes: String = "",
val forceUpdate: Boolean = false,
val publishedAt: Long = 0L
)

View File

@@ -1,396 +0,0 @@
package com.aiglasses.app.storyforge
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import com.aiglasses.app.BuildConfig
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.io.File
import java.io.FileOutputStream
import java.net.InetAddress
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import retrofit2.Retrofit
import retrofit2.create
data class StoryForgeConnectionInfo(
val rawBaseUrl: String,
val requestBaseUrl: String,
val originalHostHeader: String,
val resolvedIp: String
)
data class StoryForgeLoginResult(
val auth: AuthResponseDto,
val connection: StoryForgeConnectionInfo
)
class StoryForgeRepository(private val context: Context) {
private val appContext = context.applicationContext
private val sessionStore = StoryForgeSessionStore(appContext)
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
@Volatile
private var cachedService: StoryForgeApiService? = null
@Volatile
private var cachedConnection: StoryForgeConnectionInfo? = null
@Volatile
private var cachedToken: String = ""
fun savedSession(): SavedStoryForgeSession = sessionStore.load()
fun saveBaseUrl(baseUrl: String) {
sessionStore.saveBaseUrl(normalizeRawBaseUrl(baseUrl))
}
suspend fun resolveConnection(baseUrl: String): StoryForgeConnectionInfo = withContext(Dispatchers.IO) {
resolveConnectionInternal(baseUrl)
}
suspend fun register(baseUrl: String, username: String, password: String, displayName: String): AccountDto {
sessionStore.saveBaseUrl(normalizeRawBaseUrl(baseUrl))
return api(baseUrl = baseUrl, token = "").register(
RegisterAccountRequest(
username = username,
password = password,
display_name = displayName
)
)
}
suspend fun login(baseUrl: String, username: String, password: String): StoryForgeLoginResult {
val auth = api(baseUrl = baseUrl, token = "").login(LoginRequest(username = username, password = password))
val effectiveBaseUrl = auth.default_external_base_url.ifBlank { normalizeRawBaseUrl(baseUrl) }
sessionStore.save(effectiveBaseUrl, auth.token)
cachedService = null
val connection = apiConnection(baseUrl = effectiveBaseUrl, token = auth.token)
return StoryForgeLoginResult(auth = auth, connection = connection)
}
suspend fun logout() {
runCatching { api().logout() }
sessionStore.clearToken()
cachedToken = ""
cachedService = null
}
suspend fun me(): AccountDto = api().me()
suspend fun dashboard(): DashboardDto = api().dashboard()
suspend fun modelProfiles(): List<ModelProfileDto> = api().modelProfiles()
suspend fun createModelProfile(request: ModelProfileRequest): ModelProfileDto = api().createModelProfile(request)
suspend fun setPreferredAnalysisModel(modelProfileId: String): AccountDto =
api().setPreferredAnalysisModel(PreferredModelRequest(model_profile_id = modelProfileId))
suspend fun createKnowledgeBase(name: String, description: String): KnowledgeBaseDto =
api().createKnowledgeBase(KnowledgeBaseCreateRequest(name = name, description = description))
suspend fun knowledgeDocuments(knowledgeBaseId: String): List<KnowledgeDocumentDto> =
api().knowledgeDocuments(knowledgeBaseId)
suspend fun jobs(): List<JobDto> = api().jobs()
suspend fun job(jobId: String): JobDto = api().job(jobId)
suspend fun createVideoLinkJob(
videoUrl: String,
title: String,
knowledgeBaseId: String,
assistantId: String,
analysisModelProfileId: String
): JobDto = api().createVideoLinkJob(
ExploreVideoLinkRequest(
video_url = videoUrl,
title = title.ifBlank { null },
knowledge_base_id = knowledgeBaseId.ifBlank { null },
assistant_id = assistantId.ifBlank { null },
analysis_model_profile_id = analysisModelProfileId.ifBlank { null }
)
)
suspend fun createTextJob(
title: String,
content: String,
knowledgeBaseId: String,
assistantId: String,
analysisModelProfileId: String
): JobDto = api().createTextJob(
ExploreTextRequest(
title = title,
content = content,
knowledge_base_id = knowledgeBaseId.ifBlank { null },
assistant_id = assistantId.ifBlank { null },
analysis_model_profile_id = analysisModelProfileId.ifBlank { null }
)
)
suspend fun createContentSourceSyncJob(
platform: String,
handle: String,
sourceUrl: String,
title: String,
knowledgeBaseId: String,
assistantId: String,
analysisModelProfileId: String,
maxItems: Int,
skipExisting: Boolean,
autoTriggerAnalysis: Boolean
): JobDto = api().createContentSourceSyncJob(
ContentSourceSyncRequest(
knowledge_base_id = knowledgeBaseId,
assistant_id = assistantId,
platform = platform,
handle = handle,
source_url = sourceUrl,
title = title,
analysis_model_profile_id = analysisModelProfileId,
max_items = maxItems,
skip_existing = skipExisting,
auto_trigger_analysis = autoTriggerAnalysis
)
)
suspend fun uploadVideo(
uri: Uri,
title: String,
knowledgeBaseId: String,
assistantId: String,
analysisModelProfileId: String
): JobDto = withContext(Dispatchers.IO) {
val tempFile = copyUriToCache(uri)
try {
val filePart = MultipartBody.Part.createFormData(
name = "file",
filename = tempFile.name,
body = tempFile.asRequestBody(guessMimeType(tempFile.name).toMediaTypeOrNull())
)
api().uploadVideo(
file = filePart,
title = title.toRequestBody("text/plain".toMediaType()),
knowledgeBaseId = knowledgeBaseId.toRequestBody("text/plain".toMediaType()),
assistantId = assistantId.toRequestBody("text/plain".toMediaType()),
analysisModelProfileId = analysisModelProfileId.toRequestBody("text/plain".toMediaType())
)
} finally {
tempFile.delete()
}
}
suspend fun createAssistant(request: AssistantCreateRequest): AssistantDto = api().createAssistant(request)
suspend fun updateAssistant(assistantId: String, request: AssistantUpdateRequest): AssistantDto =
api().updateAssistant(assistantId, request)
suspend fun generateCopy(assistantId: String, request: GenerateCopyRequest): GenerateCopyResponseDto =
api().generateCopy(assistantId, request)
suspend fun pendingAccounts(): List<AccountDto> = api().pendingAccounts()
suspend fun approveAccount(accountId: String): ApprovalDecisionDto = api().approveAccount(accountId)
suspend fun rejectAccount(accountId: String): ApprovalDecisionDto = api().rejectAccount(accountId)
suspend fun latestUpdate(currentVersionCode: Int): AppUpdateLatestDto =
api().latestUpdate(currentVersionCode = currentVersionCode)
suspend fun publishAppUpdate(request: PublishAppUpdateRequest): PublishAppUpdateResponseDto =
api().publishAppUpdate(request)
suspend fun currentConnection(): StoryForgeConnectionInfo = apiConnection()
private suspend fun api(
baseUrl: String? = null,
token: String? = null
): StoryForgeApiService = withContext(Dispatchers.IO) {
val connection = apiConnection(baseUrl = baseUrl, token = token)
val authToken = token ?: sessionStore.load().token
if (cachedService != null && cachedConnection == connection && cachedToken == authToken) {
return@withContext cachedService!!
}
val client = buildClient(connection, authToken)
val retrofit = Retrofit.Builder()
.baseUrl(connection.requestBaseUrl)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
retrofit.create<StoryForgeApiService>().also {
cachedService = it
cachedConnection = connection
cachedToken = authToken
}
}
private suspend fun apiConnection(
baseUrl: String? = null,
token: String? = null
): StoryForgeConnectionInfo = withContext(Dispatchers.IO) {
val saved = sessionStore.load()
val targetBaseUrl = normalizeRawBaseUrl(baseUrl ?: saved.baseUrl)
val resolved = resolveConnectionInternal(targetBaseUrl)
cachedConnection = resolved
if (token != null) {
cachedToken = token
}
resolved
}
private fun buildClient(connection: StoryForgeConnectionInfo, token: String): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
return OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(12, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.callTimeout(150, TimeUnit.SECONDS)
.addInterceptor { chain ->
val builder: Request.Builder = chain.request().newBuilder()
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
if (connection.originalHostHeader.isNotBlank()) {
builder.header("Host", connection.originalHostHeader)
}
builder.header("Connection", "close")
chain.proceed(builder.build())
}
.addInterceptor(logging)
.build()
}
private fun normalizeRawBaseUrl(baseUrl: String): String {
val trimmed = baseUrl.trim().ifBlank { BuildConfig.DEFAULT_STORYFORGE_BASE_URL }
val migrated = when {
trimmed.startsWith("http://test.hyzq.net:8081") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
trimmed.startsWith("https://test.hyzq.net/storyforge") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
trimmed.startsWith("http://111.231.132.51:8081") -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
else -> trimmed
}
val withScheme = if (migrated.startsWith("http://") || migrated.startsWith("https://")) migrated else "http://$migrated"
return if (withScheme.endsWith('/')) withScheme else "$withScheme/"
}
private fun resolveConnectionInternal(baseUrl: String): StoryForgeConnectionInfo {
val normalized = normalizeRawBaseUrl(baseUrl)
val httpUrl = normalized.toHttpUrlOrNull() ?: error("无效后端地址: $baseUrl")
val host = httpUrl.host
val scheme = httpUrl.scheme
if (scheme == "https" || isIpHost(host) || host == "localhost" || host == "10.0.2.2") {
return StoryForgeConnectionInfo(
rawBaseUrl = normalized,
requestBaseUrl = normalized,
originalHostHeader = "",
resolvedIp = if (isIpHost(host)) host else ""
)
}
val resolvedIp = runCatching {
InetAddress.getAllByName(host).firstOrNull()?.hostAddress.orEmpty()
}.getOrDefault("")
.takeUnless { isInvalidResolvedIp(it) }
.orEmpty()
.ifBlank {
if (
host.equals("test.hyzq.net", ignoreCase = true) ||
host.equals("storyforge.hyzq.net", ignoreCase = true)
) BuildConfig.DEFAULT_STORYFORGE_FALLBACK_IP else ""
}
if (resolvedIp.isBlank()) {
return StoryForgeConnectionInfo(
rawBaseUrl = normalized,
requestBaseUrl = normalized,
originalHostHeader = "",
resolvedIp = ""
)
}
val rewritten = httpUrl.newBuilder().host(resolvedIp).build().toString()
return StoryForgeConnectionInfo(
rawBaseUrl = normalized,
requestBaseUrl = rewritten,
originalHostHeader = hostHeaderValue(httpUrl.host, httpUrl.port, scheme),
resolvedIp = resolvedIp
)
}
private fun hostHeaderValue(host: String, port: Int, scheme: String): String {
val isDefaultPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
return if (isDefaultPort) host else "$host:$port"
}
private fun isIpHost(host: String): Boolean {
return IPV4_REGEX.matches(host) || host.contains(':')
}
private fun isInvalidResolvedIp(ip: String): Boolean {
if (ip.isBlank()) return true
if (!IPV4_REGEX.matches(ip)) return false
val octets = ip.split('.').mapNotNull { it.toIntOrNull() }
if (octets.size != 4) return false
if (octets[0] == 127) return true
if (octets[0] == 0) return true
if (octets[0] == 169 && octets[1] == 254) return true
if (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) return true
return false
}
private fun copyUriToCache(uri: Uri): File {
val displayName = queryDisplayName(uri)
val safeName = displayName.ifBlank { "upload-${System.currentTimeMillis()}.mp4" }
val suffix = safeName.substringAfterLast('.', missingDelimiterValue = "mp4")
val target = File(appContext.cacheDir, "storyforge-${System.currentTimeMillis()}.$suffix")
appContext.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "无法读取所选视频" }
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
return target
}
private fun queryDisplayName(uri: Uri): String {
if (uri.scheme == "file") {
return File(uri.path.orEmpty()).name
}
val cursor = appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
cursor?.use {
val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index >= 0 && it.moveToFirst()) {
return it.getString(index).orEmpty()
}
}
return uri.lastPathSegment.orEmpty()
}
private fun guessMimeType(fileName: String): String = when {
fileName.endsWith(".mov", ignoreCase = true) -> "video/quicktime"
fileName.endsWith(".m4v", ignoreCase = true) -> "video/x-m4v"
else -> "video/mp4"
}
private companion object {
private val IPV4_REGEX = Regex("""^\\d{1,3}(?:\\.\\d{1,3}){3}$""")
}
}

View File

@@ -1,103 +0,0 @@
package com.aiglasses.app.storyforge
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.aiglasses.app.BuildConfig
data class SavedStoryForgeSession(
val baseUrl: String,
val token: String
)
class StoryForgeSessionStore(context: Context) {
private val appContext = context.applicationContext
private val legacyPrefs = appContext.getSharedPreferences(PREFS_NAME_LEGACY, Context.MODE_PRIVATE)
private val prefs: android.content.SharedPreferences by lazy { createSecurePrefs() }
init {
migrateLegacySessionIfNeeded()
}
fun load(): SavedStoryForgeSession = SavedStoryForgeSession(
baseUrl = migrateBaseUrl(prefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()),
token = prefs.getString(KEY_TOKEN, "").orEmpty()
)
fun saveBaseUrl(baseUrl: String) {
prefs.edit().putString(KEY_BASE_URL, migrateBaseUrl(baseUrl)).apply()
}
fun saveToken(token: String) {
prefs.edit().putString(KEY_TOKEN, token).apply()
}
fun save(baseUrl: String, token: String) {
prefs.edit()
.putString(KEY_BASE_URL, migrateBaseUrl(baseUrl))
.putString(KEY_TOKEN, token)
.apply()
}
fun clearToken() {
prefs.edit().remove(KEY_TOKEN).apply()
}
fun clearAll() {
prefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
}
private companion object {
private const val PREFS_NAME_LEGACY = "storyforge_session"
private const val PREFS_NAME_SECURE = "storyforge_session_secure"
private const val KEY_BASE_URL = "base_url"
private const val KEY_TOKEN = "token"
private const val LEGACY_DOMAIN_URL = "http://test.hyzq.net:8081"
private const val LEGACY_PUBLIC_URL = "https://test.hyzq.net/storyforge"
private const val LEGACY_IP_URL = "http://111.231.132.51:8081"
}
private fun createSecurePrefs(): android.content.SharedPreferences {
return runCatching {
val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
appContext,
PREFS_NAME_SECURE,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}.getOrElse {
throw IllegalStateException("Unable to create secure session storage", it)
}
}
private fun migrateLegacySessionIfNeeded() {
if (prefs.contains(KEY_BASE_URL) || prefs.contains(KEY_TOKEN)) {
return
}
if (!legacyPrefs.contains(KEY_BASE_URL) && !legacyPrefs.contains(KEY_TOKEN)) {
return
}
val baseUrl = migrateBaseUrl(
legacyPrefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()
)
val token = legacyPrefs.getString(KEY_TOKEN, "").orEmpty()
save(baseUrl, token)
legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply()
}
private fun migrateBaseUrl(baseUrl: String): String {
val trimmed = baseUrl.trim()
return when {
trimmed.isBlank() -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
trimmed.startsWith(LEGACY_DOMAIN_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
trimmed.startsWith(LEGACY_PUBLIC_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
trimmed.startsWith(LEGACY_IP_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
else -> trimmed
}
}
}

View File

@@ -1,985 +0,0 @@
package com.aiglasses.app.storyforge
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.aiglasses.app.BuildConfig
import com.aiglasses.app.update.AppOtaUpdater
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
enum class StoryForgeTab {
Overview,
Benchmark,
Agent,
Production,
Mine
}
enum class StoryForgeAuthMode {
Login,
Register
}
enum class ExploreInputMode {
ContentSource,
VideoLink,
UploadVideo,
Text
}
private const val DEFAULT_SYSTEM_PROMPT = "你是一个擅长学习短视频口播风格的 AI 文案助手,请优先保留素材中的钩子、节奏、转折和行动号召。"
private const val DEFAULT_GENERATION_GOAL = "为不同渠道生成稳定风格的短视频标题、口播脚本和收尾行动号召。"
private fun nextVersionName(current: String): String {
val parts = current.split('.').toMutableList()
val last = parts.lastOrNull()?.toIntOrNull()
if (last != null) {
parts[parts.lastIndex] = (last + 1).toString()
return parts.joinToString(".")
}
return current
}
data class StoryForgeUiState(
val authMode: StoryForgeAuthMode = StoryForgeAuthMode.Login,
val baseUrl: String = BuildConfig.DEFAULT_STORYFORGE_BASE_URL,
val resolvedBaseUrl: String = "",
val resolvedIp: String = "",
val originalHost: String = "",
val isAuthenticated: Boolean = false,
val isApproved: Boolean = false,
val currentTab: StoryForgeTab = StoryForgeTab.Overview,
val busy: Boolean = false,
val generateBusy: Boolean = false,
val statusMessage: String = "准备连接 StoryForge",
val errorMessage: String = "",
val account: AccountDto? = null,
val knowledgeBases: List<KnowledgeBaseDto> = emptyList(),
val assistants: List<AssistantDto> = emptyList(),
val modelProfiles: List<ModelProfileDto> = emptyList(),
val jobs: List<JobDto> = emptyList(),
val documents: List<KnowledgeDocumentDto> = emptyList(),
val selectedKnowledgeBaseId: String = "",
val selectedAssistantId: String = "",
val selectedAssistantKnowledgeBaseIds: Set<String> = emptySet(),
val assistantEditorId: String? = null,
val username: String = "",
val password: String = "",
val createKnowledgeBaseName: String = "",
val createKnowledgeBaseDescription: String = "",
val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink,
val accountSyncPlatform: String = "抖音",
val accountSyncHandle: String = "",
val accountSyncUrl: String = "",
val accountSyncTitle: String = "",
val accountSyncMaxItems: String = "5",
val accountSyncSkipExisting: Boolean = true,
val accountSyncAutoTriggerAnalysis: Boolean = true,
val videoUrl: String = "",
val videoTitle: String = "",
val textTitle: String = "",
val textContent: String = "",
val pickedVideoName: String = "",
val latestJobId: String = "",
val latestJob: JobDto? = null,
val assistantName: String = "",
val assistantDescription: String = "",
val assistantSystemPrompt: String = DEFAULT_SYSTEM_PROMPT,
val assistantGenerationGoal: String = DEFAULT_GENERATION_GOAL,
val assistantModelProfileId: String = "",
val generationBrief: String = "围绕 AI 创业做一条 60 秒短视频口播文案",
val generationPlatform: String = "抖音",
val generationAudience: String = "创业者",
val generationExtraRequirements: String = "开头结论先行,结尾给一个明确行动建议。",
val generationOutput: String = "",
val generationPromptExcerpt: String = "",
val newModelName: String = "",
val newModelBaseUrl: String = BuildConfig.DEFAULT_LOCAL_MODEL_BASE_URL,
val newModelApiKey: String = "",
val newModelModelName: String = "GLM-5",
val pendingAccounts: List<AccountDto> = emptyList(),
val otaInfo: AppUpdateLatestDto? = null,
val otaStatus: String = "",
val publishVersionCode: String = (BuildConfig.VERSION_CODE + 1).toString(),
val publishVersionName: String = nextVersionName(BuildConfig.VERSION_NAME),
val publishMinSupportedCode: String = BuildConfig.VERSION_CODE.toString(),
val publishApkUrl: String = "",
val publishNotes: String = "",
val publishForceUpdate: Boolean = false,
val timeline: List<String> = listOf("应用已启动,等待连接")
)
class StoryForgeViewModel(application: Application) : AndroidViewModel(application) {
private val repository = StoryForgeRepository(application.applicationContext)
private val _state = MutableStateFlow(StoryForgeUiState(baseUrl = repository.savedSession().baseUrl))
val state: StateFlow<StoryForgeUiState> = _state.asStateFlow()
private var jobPollingJob: Job? = null
private var pickedVideoUri: Uri? = null
init {
restoreSession()
}
fun updateBaseUrl(value: String) {
_state.value = _state.value.copy(baseUrl = value)
repository.saveBaseUrl(value)
}
fun updateUsername(value: String) {
_state.value = _state.value.copy(username = value)
}
fun updatePassword(value: String) {
_state.value = _state.value.copy(password = value)
}
fun setAuthMode(mode: StoryForgeAuthMode) {
_state.value = _state.value.copy(authMode = mode, errorMessage = "")
}
fun selectTab(tab: StoryForgeTab) {
_state.value = _state.value.copy(currentTab = tab)
if (tab == StoryForgeTab.Mine && state.value.account?.role == "super_admin") {
loadPendingAccounts()
}
}
fun updateCreateKnowledgeBaseName(value: String) {
_state.value = _state.value.copy(createKnowledgeBaseName = value)
}
fun updateCreateKnowledgeBaseDescription(value: String) {
_state.value = _state.value.copy(createKnowledgeBaseDescription = value)
}
fun updateVideoUrl(value: String) {
_state.value = _state.value.copy(videoUrl = value)
}
fun updateAccountSyncPlatform(value: String) {
_state.value = _state.value.copy(accountSyncPlatform = value)
}
fun updateAccountSyncHandle(value: String) {
_state.value = _state.value.copy(accountSyncHandle = value)
}
fun updateAccountSyncUrl(value: String) {
_state.value = _state.value.copy(accountSyncUrl = value)
}
fun updateAccountSyncTitle(value: String) {
_state.value = _state.value.copy(accountSyncTitle = value)
}
fun updateAccountSyncMaxItems(value: String) {
val digits = value.filter { it.isDigit() }
_state.value = _state.value.copy(accountSyncMaxItems = digits)
}
fun setAccountSyncSkipExisting(value: Boolean) {
_state.value = _state.value.copy(accountSyncSkipExisting = value)
}
fun setAccountSyncAutoTriggerAnalysis(value: Boolean) {
_state.value = _state.value.copy(accountSyncAutoTriggerAnalysis = value)
}
fun updateVideoTitle(value: String) {
_state.value = _state.value.copy(videoTitle = value)
}
fun updateTextTitle(value: String) {
_state.value = _state.value.copy(textTitle = value)
}
fun updateTextContent(value: String) {
_state.value = _state.value.copy(textContent = value)
}
fun setExploreInputMode(mode: ExploreInputMode) {
_state.value = _state.value.copy(exploreInputMode = mode, errorMessage = "")
}
fun setPickedVideo(uri: Uri?, fileName: String) {
pickedVideoUri = uri
_state.value = _state.value.copy(pickedVideoName = fileName)
}
fun selectKnowledgeBase(knowledgeBaseId: String) {
_state.value = _state.value.copy(selectedKnowledgeBaseId = knowledgeBaseId)
refreshDocuments()
}
fun selectAssistant(assistantId: String) {
val assistant = _state.value.assistants.firstOrNull { it.id == assistantId }
_state.value = _state.value.copy(
selectedAssistantId = assistantId,
selectedAssistantKnowledgeBaseIds = assistant?.knowledge_base_ids?.toSet() ?: emptySet(),
assistantEditorId = assistant?.id,
assistantName = assistant?.name.orEmpty(),
assistantDescription = assistant?.description.orEmpty(),
assistantSystemPrompt = assistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
assistantGenerationGoal = assistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
assistantModelProfileId = assistant?.model_profile_id.orEmpty(),
generationOutput = "",
generationPromptExcerpt = ""
)
}
fun startNewAssistant() {
_state.value = _state.value.copy(
assistantEditorId = null,
assistantName = "",
assistantDescription = "",
assistantSystemPrompt = DEFAULT_SYSTEM_PROMPT,
assistantGenerationGoal = DEFAULT_GENERATION_GOAL,
assistantModelProfileId = preferredModelId(),
selectedAssistantKnowledgeBaseIds = listOfNotNull(state.value.selectedKnowledgeBaseId.takeIf { it.isNotBlank() }).toSet()
)
}
fun toggleAssistantKnowledgeBase(knowledgeBaseId: String) {
val updated = _state.value.selectedAssistantKnowledgeBaseIds.toMutableSet()
if (!updated.add(knowledgeBaseId)) {
updated.remove(knowledgeBaseId)
}
_state.value = _state.value.copy(selectedAssistantKnowledgeBaseIds = updated)
}
fun updateAssistantName(value: String) {
_state.value = _state.value.copy(assistantName = value)
}
fun updateAssistantDescription(value: String) {
_state.value = _state.value.copy(assistantDescription = value)
}
fun updateAssistantSystemPrompt(value: String) {
_state.value = _state.value.copy(assistantSystemPrompt = value)
}
fun updateAssistantGenerationGoal(value: String) {
_state.value = _state.value.copy(assistantGenerationGoal = value)
}
fun updateAssistantModelProfileId(value: String) {
_state.value = _state.value.copy(assistantModelProfileId = value)
}
fun updateGenerationBrief(value: String) {
_state.value = _state.value.copy(generationBrief = value)
}
fun updateGenerationPlatform(value: String) {
_state.value = _state.value.copy(generationPlatform = value)
}
fun updateGenerationAudience(value: String) {
_state.value = _state.value.copy(generationAudience = value)
}
fun updateGenerationExtraRequirements(value: String) {
_state.value = _state.value.copy(generationExtraRequirements = value)
}
fun updateNewModelName(value: String) {
_state.value = _state.value.copy(newModelName = value)
}
fun updateNewModelBaseUrl(value: String) {
_state.value = _state.value.copy(newModelBaseUrl = value)
}
fun updateNewModelApiKey(value: String) {
_state.value = _state.value.copy(newModelApiKey = value)
}
fun updateNewModelModelName(value: String) {
_state.value = _state.value.copy(newModelModelName = value)
}
fun updatePublishVersionCode(value: String) {
_state.value = _state.value.copy(publishVersionCode = value)
}
fun updatePublishVersionName(value: String) {
_state.value = _state.value.copy(publishVersionName = value)
}
fun updatePublishMinSupportedCode(value: String) {
_state.value = _state.value.copy(publishMinSupportedCode = value)
}
fun updatePublishApkUrl(value: String) {
_state.value = _state.value.copy(publishApkUrl = value)
}
fun updatePublishNotes(value: String) {
_state.value = _state.value.copy(publishNotes = value)
}
fun setPublishForceUpdate(value: Boolean) {
_state.value = _state.value.copy(publishForceUpdate = value)
}
fun registerAccount() {
val current = state.value
if (current.username.isBlank() || current.password.isBlank()) {
setError("请填写用户名和密码")
return
}
runBusy(message = "正在提交注册申请...", task = {
repository.register(
baseUrl = current.baseUrl,
username = current.username.trim(),
password = current.password,
displayName = current.username.trim()
)
}) { account ->
appendTimeline("账号 ${account.username} 已注册,等待主管理员审批")
_state.value = _state.value.copy(
authMode = StoryForgeAuthMode.Login,
password = "",
statusMessage = "注册成功,请等待主管理员审批",
errorMessage = ""
)
}
}
fun login() {
val current = state.value
if (current.username.isBlank() || current.password.isBlank()) {
setError("请先填写用户名和密码")
return
}
runBusy(message = "正在登录 StoryForge...", task = {
repository.login(
baseUrl = current.baseUrl,
username = current.username.trim(),
password = current.password
)
}) { result ->
applyConnection(result.connection)
appendTimeline("账号 ${result.auth.account.username} 登录成功")
val account = result.auth.account
_state.value = _state.value.copy(
isAuthenticated = true,
isApproved = account.approval_status == "approved",
account = account,
password = "",
statusMessage = if (account.approval_status == "approved") "登录成功,正在同步工作台" else "账号待主管理员审批",
errorMessage = ""
)
if (account.approval_status == "approved") {
refreshWorkspace()
}
}
}
fun refreshApprovalStatus() {
runBusy(message = "正在刷新审批状态...", task = {
repository.me() to repository.currentConnection()
}) { (account, connection) ->
applyConnection(connection)
_state.value = _state.value.copy(
isAuthenticated = true,
isApproved = account.approval_status == "approved",
account = account,
statusMessage = if (account.approval_status == "approved") "审批已通过,正在同步工作台" else "当前账号仍在等待审批",
errorMessage = ""
)
appendTimeline("审批状态更新为 ${account.approval_status}")
if (account.approval_status == "approved") {
refreshWorkspace()
}
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
jobPollingJob?.cancel()
pickedVideoUri = null
appendTimeline("已退出当前账号")
_state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl)
}
}
fun refreshWorkspace() {
viewModelScope.launch {
val current = state.value
_state.value = current.copy(busy = true, errorMessage = "", statusMessage = "正在同步工作台数据...")
runCatching {
val me = repository.me()
val connection = repository.currentConnection()
if (me.approval_status != "approved") {
Triple(me, connection, null)
} else {
Triple(me, connection, repository.dashboard())
}
}.onSuccess { (account, connection, dashboard) ->
applyConnection(connection)
if (dashboard == null) {
_state.value = state.value.copy(
busy = false,
isAuthenticated = true,
isApproved = false,
account = account,
statusMessage = "账号待主管理员审批"
)
} else {
applyDashboard(account, dashboard)
}
}.onFailure { throwable ->
if (throwable is HttpException && throwable.code() == 401) {
repository.logout()
_state.value = StoryForgeUiState(baseUrl = repository.savedSession().baseUrl).copy(
errorMessage = "登录已失效,请重新登录",
statusMessage = "请重新登录 StoryForge"
)
} else {
_state.value = state.value.copy(
busy = false,
errorMessage = throwable.toReadableMessage(),
statusMessage = "同步失败,请检查网络或稍后重试"
)
appendTimeline("同步失败: ${throwable.toReadableMessage()}")
}
}
}
}
fun createKnowledgeBase() {
val current = state.value
if (current.createKnowledgeBaseName.isBlank()) {
setError("请先填写知识库名称")
return
}
runBusy(message = "正在创建知识库...", task = {
repository.createKnowledgeBase(current.createKnowledgeBaseName.trim(), current.createKnowledgeBaseDescription.trim())
}) { knowledgeBase ->
appendTimeline("已创建知识库 ${knowledgeBase.name}")
_state.value = state.value.copy(
createKnowledgeBaseName = "",
createKnowledgeBaseDescription = "",
selectedKnowledgeBaseId = knowledgeBase.id
)
refreshWorkspace()
}
}
fun submitVideoLink() {
val current = state.value
if (current.videoUrl.isBlank()) {
setError("请先输入视频链接")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
runBusy(message = "正在提交视频学习任务...", task = {
repository.createVideoLinkJob(
videoUrl = current.videoUrl.trim(),
title = current.videoTitle.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId()
)
}) { job ->
appendTimeline("视频链接任务已创建: ${job.title}")
_state.value = state.value.copy(videoUrl = "", videoTitle = "")
afterJobCreated(job)
}
}
fun submitContentSourceSync() {
val current = state.value
if (current.accountSyncUrl.isBlank()) {
setError("请先输入账号主页链接")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
val maxItems = current.accountSyncMaxItems.toIntOrNull()?.coerceIn(1, 20) ?: 5
runBusy(message = "正在创建账号同步任务...", task = {
repository.createContentSourceSyncJob(
platform = current.accountSyncPlatform.trim(),
handle = current.accountSyncHandle.trim(),
sourceUrl = current.accountSyncUrl.trim(),
title = current.accountSyncTitle.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId(),
maxItems = maxItems,
skipExisting = current.accountSyncSkipExisting,
autoTriggerAnalysis = current.accountSyncAutoTriggerAnalysis
)
}) { job ->
appendTimeline("账号同步任务已创建: ${job.title}")
_state.value = state.value.copy(
accountSyncHandle = "",
accountSyncUrl = "",
accountSyncTitle = "",
accountSyncMaxItems = maxItems.toString()
)
afterJobCreated(job)
}
}
fun submitText() {
val current = state.value
if (current.textTitle.isBlank() || current.textContent.isBlank()) {
setError("请输入素材标题和文字内容")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
runBusy(message = "正在提交文字分析任务...", task = {
repository.createTextJob(
title = current.textTitle.trim(),
content = current.textContent.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId()
)
}) { job ->
appendTimeline("文字素材已进入分析队列: ${job.title}")
_state.value = state.value.copy(textTitle = "", textContent = "")
afterJobCreated(job)
}
}
fun submitUploadVideo() {
val current = state.value
val uri = pickedVideoUri
if (uri == null) {
setError("请先选择本地视频文件")
return
}
val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback()
if (knowledgeBaseId.isBlank()) {
setError("请先选择知识库")
return
}
runBusy(message = "正在上传视频并创建学习任务...", task = {
repository.uploadVideo(
uri = uri,
title = current.videoTitle.trim(),
knowledgeBaseId = knowledgeBaseId,
assistantId = current.selectedAssistantId,
analysisModelProfileId = preferredModelId()
)
}) { job ->
appendTimeline("视频上传成功,任务已创建: ${job.title}")
pickedVideoUri = null
_state.value = state.value.copy(videoTitle = "", pickedVideoName = "")
afterJobCreated(job)
}
}
fun saveAssistant() {
val current = state.value
if (current.assistantName.isBlank()) {
setError("请先填写智能体名称")
return
}
if (current.selectedAssistantKnowledgeBaseIds.isEmpty()) {
setError("请至少关联一个知识库")
return
}
val request = AssistantCreateRequest(
name = current.assistantName.trim(),
description = current.assistantDescription.trim(),
system_prompt = current.assistantSystemPrompt.trim(),
generation_goal = current.assistantGenerationGoal.trim(),
knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList(),
model_profile_id = current.assistantModelProfileId.ifBlank { preferredModelId() }
)
if (current.assistantEditorId.isNullOrBlank()) {
runBusy(message = "正在创建智能体...", task = {
repository.createAssistant(request)
}) { assistant ->
appendTimeline("已创建智能体 ${assistant.name}")
_state.value = state.value.copy(selectedAssistantId = assistant.id)
refreshWorkspace()
}
} else {
runBusy(message = "正在保存智能体配置...", task = {
repository.updateAssistant(
current.assistantEditorId,
AssistantUpdateRequest(
name = request.name,
description = request.description,
system_prompt = request.system_prompt,
generation_goal = request.generation_goal,
knowledge_base_ids = request.knowledge_base_ids,
model_profile_id = request.model_profile_id
)
)
}) { assistant ->
appendTimeline("已更新智能体 ${assistant.name}")
_state.value = state.value.copy(selectedAssistantId = assistant.id)
refreshWorkspace()
}
}
}
fun generateCopy() {
val current = state.value
val assistantId = current.selectedAssistantId.ifBlank { current.assistantEditorId.orEmpty() }
if (assistantId.isBlank()) {
setError("请先选择一个智能体")
return
}
if (current.generationBrief.isBlank()) {
setError("请先填写文案需求")
return
}
viewModelScope.launch {
_state.value = state.value.copy(generateBusy = true, errorMessage = "", statusMessage = "正在生成文案,请稍候...")
runCatching {
repository.generateCopy(
assistantId,
GenerateCopyRequest(
brief = current.generationBrief.trim(),
platform = current.generationPlatform.trim(),
audience = current.generationAudience.trim(),
extra_requirements = current.generationExtraRequirements.trim(),
knowledge_base_ids = current.selectedAssistantKnowledgeBaseIds.toList()
)
)
}.onSuccess { result ->
_state.value = state.value.copy(
generateBusy = false,
generationOutput = result.content,
generationPromptExcerpt = result.prompt_excerpt,
statusMessage = "文案生成完成"
)
appendTimeline("智能体已生成一条新文案")
}.onFailure { throwable ->
_state.value = state.value.copy(
generateBusy = false,
errorMessage = throwable.toReadableMessage(),
statusMessage = "文案生成失败"
)
appendTimeline("文案生成失败: ${throwable.toReadableMessage()}")
}
}
}
fun createModelProfile() {
val current = state.value
if (current.newModelName.isBlank() || current.newModelBaseUrl.isBlank() || current.newModelApiKey.isBlank() || current.newModelModelName.isBlank()) {
setError("请完整填写模型名称、Base URL、API Key 和模型名")
return
}
runBusy(message = "正在保存模型配置...", task = {
repository.createModelProfile(
ModelProfileRequest(
name = current.newModelName.trim(),
base_url = current.newModelBaseUrl.trim(),
api_key = current.newModelApiKey.trim(),
model_name = current.newModelModelName.trim(),
is_default = true
)
)
}) { profile ->
appendTimeline("已新增模型配置 ${profile.name}")
_state.value = state.value.copy(
newModelName = "",
newModelApiKey = "",
newModelModelName = current.newModelModelName,
assistantModelProfileId = profile.id
)
refreshWorkspace()
}
}
fun setPreferredModel(modelProfileId: String) {
runBusy(message = "正在切换默认分析模型...", task = {
repository.setPreferredAnalysisModel(modelProfileId)
}) { account ->
_state.value = state.value.copy(account = account)
appendTimeline("已切换默认分析模型")
refreshWorkspace()
}
}
fun loadPendingAccounts() {
if (state.value.account?.role != "super_admin") return
viewModelScope.launch {
runCatching { repository.pendingAccounts() }
.onSuccess { pending ->
_state.value = state.value.copy(pendingAccounts = pending)
}
.onFailure { throwable ->
_state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
}
}
}
fun approveAccount(accountId: String) {
runBusy(message = "正在通过账号审批...", task = {
repository.approveAccount(accountId)
}) {
appendTimeline("已通过一条账号审批")
refreshWorkspace()
}
}
fun rejectAccount(accountId: String) {
runBusy(message = "正在拒绝账号申请...", task = {
repository.rejectAccount(accountId)
}) {
appendTimeline("已拒绝一条账号申请")
refreshWorkspace()
}
}
fun checkForUpdates() {
viewModelScope.launch {
_state.value = state.value.copy(otaStatus = "正在检查更新...")
runCatching { repository.latestUpdate(BuildConfig.VERSION_CODE) }
.onSuccess { latest ->
_state.value = state.value.copy(
otaInfo = latest,
otaStatus = if (latest.hasUpdate) {
"发现新版本 ${latest.latestVersionName} (${latest.latestVersionCode})"
} else {
"当前已经是最新版本"
}
)
appendTimeline("OTA 检查完成")
}
.onFailure { throwable ->
_state.value = state.value.copy(otaStatus = throwable.toReadableMessage(), errorMessage = throwable.toReadableMessage())
}
}
}
fun publishUpdate() {
val current = state.value
val versionCode = current.publishVersionCode.toIntOrNull()
val minSupportedCode = current.publishMinSupportedCode.toIntOrNull()
if (versionCode == null || minSupportedCode == null || current.publishVersionName.isBlank() || current.publishApkUrl.isBlank()) {
setError("请完整填写 OTA 的版本号、最小支持版本、下载地址")
return
}
runBusy(message = "正在发布 OTA 配置...", task = {
repository.publishAppUpdate(
PublishAppUpdateRequest(
versionCode = versionCode,
versionName = current.publishVersionName.trim(),
minSupportedCode = minSupportedCode,
apkUrl = current.publishApkUrl.trim(),
notes = current.publishNotes.trim(),
forceUpdate = current.publishForceUpdate
)
)
}) { response ->
_state.value = state.value.copy(otaStatus = "已发布 OTA: ${response.action}")
appendTimeline("主管理员已发布 OTA ${current.publishVersionName}")
checkForUpdates()
}
}
fun onOtaLog(message: String) {
appendTimeline(message)
_state.value = state.value.copy(otaStatus = message)
}
fun installLatestUpdate(otaUpdater: AppOtaUpdater) {
val latest = state.value.otaInfo
if (latest == null || !latest.hasUpdate || latest.downloadUrl.isBlank()) {
setError("当前没有可安装的更新")
return
}
val started = otaUpdater.downloadAndInstall(
apkUrl = latest.downloadUrl,
versionName = latest.latestVersionName.ifBlank { "${latest.latestVersionCode}" },
expectedSha256 = latest.apkSha256
)
_state.value = state.value.copy(otaStatus = if (started) "OTA 下载已启动" else "OTA 下载启动失败")
}
private fun restoreSession() {
val saved = repository.savedSession()
_state.value = state.value.copy(baseUrl = saved.baseUrl)
if (saved.token.isBlank()) {
viewModelScope.launch {
runCatching { repository.resolveConnection(saved.baseUrl) }
.onSuccess { applyConnection(it) }
}
return
}
refreshWorkspace()
}
private fun refreshDocuments() {
val knowledgeBaseId = state.value.selectedKnowledgeBaseId
if (knowledgeBaseId.isBlank() || !state.value.isApproved) return
viewModelScope.launch {
runCatching { repository.knowledgeDocuments(knowledgeBaseId) }
.onSuccess { documents ->
_state.value = state.value.copy(documents = documents)
}
.onFailure { throwable ->
_state.value = state.value.copy(errorMessage = throwable.toReadableMessage())
}
}
}
private fun afterJobCreated(job: JobDto) {
_state.value = state.value.copy(
latestJob = job,
latestJobId = job.id,
currentTab = StoryForgeTab.Benchmark
)
refreshWorkspace()
startJobPolling(job.id)
}
private fun startJobPolling(jobId: String) {
jobPollingJob?.cancel()
jobPollingJob = viewModelScope.launch {
repeat(30) {
delay(5000)
runCatching { repository.job(jobId) }
.onSuccess { job ->
_state.value = state.value.copy(latestJob = job, latestJobId = job.id)
if (job.status == "completed" || job.status == "failed") {
appendTimeline("素材任务 ${job.title}${if (job.status == "completed") "完成" else "失败"}")
refreshWorkspace()
return@launch
}
}
}
}
}
private fun applyDashboard(account: AccountDto, dashboard: DashboardDto) {
val selectedKbId = state.value.selectedKnowledgeBaseId.takeIf { id -> dashboard.knowledge_bases.any { it.id == id } }
?: dashboard.knowledge_bases.firstOrNull()?.id.orEmpty()
val selectedAssistantId = state.value.selectedAssistantId.takeIf { id -> dashboard.assistants.any { it.id == id } }
?: dashboard.assistants.firstOrNull()?.id.orEmpty()
val selectedAssistant = dashboard.assistants.firstOrNull { it.id == selectedAssistantId }
_state.value = state.value.copy(
busy = false,
isAuthenticated = true,
isApproved = true,
account = account,
knowledgeBases = dashboard.knowledge_bases,
assistants = dashboard.assistants,
modelProfiles = dashboard.model_profiles,
jobs = dashboard.recent_jobs,
documents = emptyList(),
selectedKnowledgeBaseId = selectedKbId,
selectedAssistantId = selectedAssistantId,
selectedAssistantKnowledgeBaseIds = selectedAssistant?.knowledge_base_ids?.toSet()
?: listOfNotNull(selectedKbId.takeIf { it.isNotBlank() }).toSet(),
assistantEditorId = selectedAssistant?.id,
assistantName = selectedAssistant?.name.orEmpty(),
assistantDescription = selectedAssistant?.description.orEmpty(),
assistantSystemPrompt = selectedAssistant?.system_prompt ?: DEFAULT_SYSTEM_PROMPT,
assistantGenerationGoal = selectedAssistant?.generation_goal ?: DEFAULT_GENERATION_GOAL,
assistantModelProfileId = (selectedAssistant?.model_profile_id ?: "").ifBlank { preferredModelId(dashboard, account) },
latestJob = dashboard.recent_jobs.firstOrNull(),
latestJobId = dashboard.recent_jobs.firstOrNull()?.id.orEmpty(),
pendingAccounts = if (account.role == "super_admin") state.value.pendingAccounts else emptyList(),
statusMessage = "工作台已同步完成",
errorMessage = ""
)
refreshDocuments()
if (account.role == "super_admin") {
loadPendingAccounts()
}
}
private fun preferredModelId(
dashboard: DashboardDto? = null,
account: AccountDto? = state.value.account
): String {
val currentDashboard = dashboard
val accountPreferred = account?.preferred_analysis_model_id.orEmpty()
if (accountPreferred.isNotBlank()) return accountPreferred
val profiles = currentDashboard?.model_profiles ?: state.value.modelProfiles
return profiles.firstOrNull { it.is_default }?.id.orEmpty()
}
private fun selectedKnowledgeBaseIdOrFallback(): String {
return state.value.selectedKnowledgeBaseId.ifBlank {
state.value.knowledgeBases.firstOrNull()?.id.orEmpty()
}
}
private fun applyConnection(connection: StoryForgeConnectionInfo) {
_state.value = state.value.copy(
baseUrl = connection.rawBaseUrl,
resolvedBaseUrl = connection.requestBaseUrl,
resolvedIp = connection.resolvedIp,
originalHost = connection.originalHostHeader
)
}
private fun setError(message: String) {
_state.value = state.value.copy(errorMessage = message, statusMessage = message)
}
private fun appendTimeline(message: String) {
val next = (listOf(message) + state.value.timeline).distinct().take(16)
_state.value = state.value.copy(timeline = next)
}
private fun <T> runBusy(
message: String,
task: suspend () -> T,
onSuccess: (T) -> Unit
) {
viewModelScope.launch {
_state.value = state.value.copy(busy = true, errorMessage = "", statusMessage = message)
runCatching { task() }
.onSuccess { result ->
_state.value = state.value.copy(busy = false, errorMessage = "")
onSuccess(result)
}
.onFailure { throwable ->
_state.value = state.value.copy(
busy = false,
errorMessage = throwable.toReadableMessage(),
statusMessage = throwable.toReadableMessage()
)
appendTimeline(throwable.toReadableMessage())
}
}
}
}
private fun Throwable.toReadableMessage(): String {
if (this is HttpException) {
val body = response()?.errorBody()?.string().orEmpty()
return if (body.isNotBlank()) {
body.take(240)
} else {
"请求失败 (${code()})"
}
}
return message ?: "发生未知错误"
}

View File

@@ -1,105 +0,0 @@
package com.aiglasses.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val LightColors = lightColorScheme(
primary = Color(0xFF4E89F5),
secondary = Color(0xFF87AEEB),
tertiary = Color(0xFF17283A),
background = Color(0xFFF2F7FF),
surface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFFEAF2FF),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF152332),
onSurface = Color(0xFF152332),
outline = Color(0xFFC9D8EA)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF8CB7FF),
secondary = Color(0xFF7EA5DE),
tertiary = Color(0xFFE6EEF9),
background = Color(0xFF101823),
surface = Color(0xFF162131),
surfaceVariant = Color(0xFF1D2B3D),
onPrimary = Color(0xFF0C1B30),
onSecondary = Color(0xFF0C1B30),
onBackground = Color(0xFFEAF1FB),
onSurface = Color(0xFFEAF1FB),
outline = Color(0xFF35506F)
)
private val AppTypography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 30.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 26.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 14.sp,
lineHeight = 21.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 12.sp,
lineHeight = 18.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 11.sp
)
)
@Composable
fun AIGlassesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = AppTypography,
content = content
)
}

View File

@@ -1,559 +0,0 @@
package com.aiglasses.app.update
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
class AppOtaUpdater(
context: Context,
private val onLog: (String) -> Unit
) {
private val appContext = context.applicationContext
private val downloadManager = appContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val mainHandler = Handler(Looper.getMainLooper())
private var receiverRegistered = false
private var activeDownloadId = -1L
private var activeDownloadUrl = ""
private var activeExpectedSha256 = ""
private var activeFileName = ""
private var progressTask: Runnable? = null
private var lastProgressPercent = -1
private var lastProgressLogAt = 0L
private var lastProgressBytes = -1L
private var lastProgressBytesAt = 0L
private data class DownloadSnapshot(
val exists: Boolean = false,
val status: Int = 0,
val reason: Int = -1,
val soFar: Long = 0L,
val total: Long = 0L,
val url: String = ""
)
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
if (id <= 0 || id != activeDownloadId) return
handleDownloadComplete(id)
}
}
fun register() {
if (receiverRegistered) return
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.registerReceiver(downloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
appContext.registerReceiver(downloadReceiver, filter)
}
receiverRegistered = true
recoverTrackedDownload()
}
fun release() {
if (!receiverRegistered) return
runCatching { appContext.unregisterReceiver(downloadReceiver) }
receiverRegistered = false
stopProgressPolling()
}
fun downloadAndInstall(apkUrl: String, versionName: String, expectedSha256: String = ""): Boolean {
val url = apkUrl.trim()
if (url.isBlank()) {
onLog("OTA: missing apk url")
return false
}
val expected = expectedSha256.trim().lowercase()
recoverTrackedDownload()
val existing = findDownloadByUrl(url)
if (existing > 0) {
val snapshot = queryDownload(existing)
when (snapshot.status) {
DownloadManager.STATUS_SUCCESSFUL -> {
onLog("OTA: 发现已下载完成任务,直接安装 id=$existing")
activeDownloadId = existing
activeDownloadUrl = url
activeExpectedSha256 = expected
persistTrackedDownload()
handleDownloadComplete(existing)
return true
}
DownloadManager.STATUS_PENDING,
DownloadManager.STATUS_PAUSED,
DownloadManager.STATUS_RUNNING -> {
activeDownloadId = existing
activeDownloadUrl = url
activeExpectedSha256 = expected
if (activeFileName.isBlank()) {
activeFileName = buildStableFileName(versionName)
}
persistTrackedDownload()
onLog("OTA: 继续已有下载任务 id=$existing")
startProgressPolling(existing)
return true
}
}
if (snapshot.status == DownloadManager.STATUS_FAILED) {
onLog("OTA: 清理失败下载任务 id=$existing 后重试")
runCatching { downloadManager.remove(existing) }
if (activeDownloadId == existing) {
clearTrackedDownload()
}
}
}
val fileName = buildStableFileName(versionName)
val req = DownloadManager.Request(Uri.parse(url))
.setTitle("AI Glasses 更新包")
.setDescription("下载并安装 $versionName")
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setMimeType("application/vnd.android.package-archive")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalFilesDir(appContext, Environment.DIRECTORY_DOWNLOADS, fileName)
if (activeDownloadId > 0 && activeDownloadUrl != url) {
onLog("OTA: 切换到新下载地址,取消旧任务 id=$activeDownloadId")
runCatching { downloadManager.remove(activeDownloadId) }
}
stopProgressPolling()
resetProgressTracking()
activeDownloadUrl = url
activeExpectedSha256 = expected
activeFileName = fileName
activeDownloadId = runCatching { downloadManager.enqueue(req) }
.onFailure { onLog("OTA: download enqueue failed: ${it.message}") }
.getOrDefault(-1L)
if (activeDownloadId <= 0) return false
persistTrackedDownload()
onLog("OTA: 开始下载更新包 id=$activeDownloadId")
onLog("OTA: 下载地址 ${url.take(120)}")
startProgressPolling(activeDownloadId)
return true
}
private fun handleDownloadComplete(downloadId: Long) {
stopProgressPolling()
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
cursor.use { c ->
if (!c.moveToFirst()) {
onLog("OTA: 下载任务不存在 id=$downloadId")
clearTrackedDownload()
return
}
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (statusIdx < 0) {
onLog("OTA: 无法读取下载状态")
clearTrackedDownload()
return
}
val status = c.getInt(statusIdx)
if (status != DownloadManager.STATUS_SUCCESSFUL) {
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
onLog("OTA: 下载失败 status=$status reason=${reasonToText(reason)}($reason)")
clearTrackedDownload()
return
}
}
onLog("OTA: 下载完成 id=$downloadId")
val uri = downloadManager.getUriForDownloadedFile(downloadId)
if (uri == null) {
onLog("OTA: 找不到已下载文件 URI")
clearTrackedDownload()
return
}
if (!verifyDownloadedApkSha256(uri, activeExpectedSha256)) {
clearTrackedDownload()
return
}
if (!canInstallPackages()) {
openInstallPermissionSettings()
onLog("OTA: 下载完成,请允许本应用安装未知来源后再次点击更新")
persistTrackedDownload()
return
}
val installUri = materializeInstallUri(uri, activeFileName)
if (installUri == null) {
onLog("OTA: 无法准备安装包")
clearTrackedDownload()
return
}
val ok = installApk(installUri)
onLog(if (ok) "OTA: 已拉起安装流程" else "OTA: 拉起安装失败")
clearTrackedDownload()
}
private fun startProgressPolling(downloadId: Long) {
stopProgressPolling()
val task = object : Runnable {
override fun run() {
if (activeDownloadId != downloadId || activeDownloadId <= 0) return
val keep = emitDownloadProgress(downloadId)
if (!keep) return
mainHandler.postDelayed(this, 1000L)
}
}
progressTask = task
mainHandler.post(task)
}
private fun stopProgressPolling() {
progressTask?.let { mainHandler.removeCallbacks(it) }
progressTask = null
}
private fun emitDownloadProgress(downloadId: Long): Boolean {
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
cursor.use { c ->
if (!c.moveToFirst()) {
onLog("OTA: 下载任务丢失 id=$downloadId")
clearTrackedDownload()
return false
}
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
if (statusIdx < 0 || soFarIdx < 0 || totalIdx < 0) {
return true
}
val status = c.getInt(statusIdx)
val soFar = c.getLong(soFarIdx).coerceAtLeast(0L)
val total = c.getLong(totalIdx).coerceAtLeast(0L)
val percent = if (total > 0L) {
((soFar * 100L) / total).toInt().coerceIn(0, 100)
} else {
-1
}
val now = SystemClock.elapsedRealtime()
when {
soFar > lastProgressBytes -> {
lastProgressBytes = soFar
lastProgressBytesAt = now
}
lastProgressBytes < 0L -> {
lastProgressBytes = soFar
lastProgressBytesAt = now
}
}
val shouldLog = when {
status == DownloadManager.STATUS_RUNNING && percent >= 0 ->
(percent != lastProgressPercent && (percent % 2 == 0 || percent >= 98)) ||
(now - lastProgressLogAt >= 4_000L)
status == DownloadManager.STATUS_RUNNING ->
now - lastProgressLogAt >= 3_000L
status == DownloadManager.STATUS_PENDING || status == DownloadManager.STATUS_PAUSED ->
now - lastProgressLogAt >= 3000L
else -> false
}
if (shouldLog) {
lastProgressLogAt = now
if (percent >= 0) {
lastProgressPercent = percent
onLog(
"OTA: 下载进度 $percent% (${formatBytes(soFar)}/${formatBytes(total)}) status=${statusToText(status)}"
)
} else {
val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
onLog(
if (status == DownloadManager.STATUS_RUNNING) {
"OTA: 下载中 ${formatBytes(soFar)} (总大小未知)"
} else {
"OTA: 下载状态=${statusToText(status)} reason=${reasonToText(reason)} ${formatBytes(soFar)}"
}
)
}
}
return when (status) {
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED, DownloadManager.STATUS_RUNNING -> true
DownloadManager.STATUS_SUCCESSFUL -> {
handleDownloadComplete(downloadId)
false
}
DownloadManager.STATUS_FAILED -> {
val reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1
onLog("OTA: 下载失败 reason=${reasonToText(reason)}($reason)")
clearTrackedDownload()
false
}
else -> true
}
}
}
private fun recoverTrackedDownload() {
if (activeDownloadId <= 0L) {
activeDownloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1L)
activeDownloadUrl = prefs.getString(KEY_DOWNLOAD_URL, "") ?: ""
activeExpectedSha256 = prefs.getString(KEY_EXPECTED_SHA256, "") ?: ""
activeFileName = prefs.getString(KEY_FILE_NAME, "") ?: ""
}
if (activeDownloadId <= 0L) return
val snapshot = queryDownload(activeDownloadId)
if (!snapshot.exists) {
clearTrackedDownload()
return
}
if (activeDownloadUrl.isBlank()) {
activeDownloadUrl = snapshot.url
}
when (snapshot.status) {
DownloadManager.STATUS_PENDING,
DownloadManager.STATUS_PAUSED,
DownloadManager.STATUS_RUNNING -> {
onLog("OTA: 恢复下载任务 id=$activeDownloadId")
persistTrackedDownload()
resetProgressTracking(snapshot.soFar)
startProgressPolling(activeDownloadId)
}
DownloadManager.STATUS_SUCCESSFUL -> {
onLog("OTA: 检测到已完成下载任务,继续安装")
handleDownloadComplete(activeDownloadId)
}
DownloadManager.STATUS_FAILED -> {
onLog(
"OTA: 上次下载任务已失败 reason=${reasonToText(snapshot.reason)}(${snapshot.reason})"
)
clearTrackedDownload()
}
else -> {
persistTrackedDownload()
}
}
}
private fun findDownloadByUrl(url: String): Long {
if (activeDownloadId > 0L && activeDownloadUrl == url) {
val active = queryDownload(activeDownloadId)
if (active.exists) return activeDownloadId
}
val savedId = prefs.getLong(KEY_DOWNLOAD_ID, -1L)
val savedUrl = prefs.getString(KEY_DOWNLOAD_URL, "") ?: ""
if (savedId > 0L && savedUrl == url) {
val saved = queryDownload(savedId)
if (saved.exists) return savedId
}
val query = DownloadManager.Query().setFilterByStatus(
DownloadManager.STATUS_PENDING or
DownloadManager.STATUS_PAUSED or
DownloadManager.STATUS_RUNNING or
DownloadManager.STATUS_SUCCESSFUL
)
val cursor = downloadManager.query(query)
var latestId = -1L
cursor.use { c ->
val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
val urlIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
if (idIdx < 0 || urlIdx < 0) return@use
while (c.moveToNext()) {
val itemUrl = c.getString(urlIdx).orEmpty()
if (itemUrl != url) continue
val id = c.getLong(idIdx)
if (id > latestId) latestId = id
}
}
return latestId
}
private fun queryDownload(downloadId: Long): DownloadSnapshot {
if (downloadId <= 0L) return DownloadSnapshot()
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
cursor.use { c ->
if (!c.moveToFirst()) return DownloadSnapshot()
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val urlIdx = c.getColumnIndex(DownloadManager.COLUMN_URI)
return DownloadSnapshot(
exists = true,
status = if (statusIdx >= 0) c.getInt(statusIdx) else 0,
reason = if (reasonIdx >= 0) c.getInt(reasonIdx) else -1,
soFar = if (soFarIdx >= 0) c.getLong(soFarIdx) else 0L,
total = if (totalIdx >= 0) c.getLong(totalIdx) else 0L,
url = if (urlIdx >= 0) c.getString(urlIdx).orEmpty() else ""
)
}
}
private fun persistTrackedDownload() {
if (activeDownloadId <= 0L) return
prefs.edit()
.putLong(KEY_DOWNLOAD_ID, activeDownloadId)
.putString(KEY_DOWNLOAD_URL, activeDownloadUrl)
.putString(KEY_EXPECTED_SHA256, activeExpectedSha256)
.putString(KEY_FILE_NAME, activeFileName)
.apply()
}
private fun clearTrackedDownload() {
activeDownloadId = -1L
activeDownloadUrl = ""
activeExpectedSha256 = ""
activeFileName = ""
resetProgressTracking()
prefs.edit()
.remove(KEY_DOWNLOAD_ID)
.remove(KEY_DOWNLOAD_URL)
.remove(KEY_EXPECTED_SHA256)
.remove(KEY_FILE_NAME)
.apply()
}
private fun buildStableFileName(versionName: String): String {
val safeName = versionName.ifBlank { "latest" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
return "ai-glasses-$safeName.apk"
}
private fun resetProgressTracking(initialBytes: Long = -1L) {
lastProgressPercent = -1
lastProgressLogAt = 0L
lastProgressBytes = initialBytes
lastProgressBytesAt = if (initialBytes >= 0L) SystemClock.elapsedRealtime() else 0L
}
private fun verifyDownloadedApkSha256(uri: Uri, expectedSha256: String): Boolean {
if (expectedSha256.isBlank()) return true
val digest = runCatching {
val md = MessageDigest.getInstance("SHA-256")
appContext.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(16 * 1024)
while (true) {
val n = input.read(buffer)
if (n <= 0) break
md.update(buffer, 0, n)
}
} ?: return false
md.digest().joinToString("") { "%02x".format(it) }
}.onFailure {
onLog("OTA: 校验失败 ${it.message}")
}.getOrNull() ?: return false
if (digest != expectedSha256) {
onLog("OTA: 文件校验不匹配 expected=${expectedSha256.take(10)} actual=${digest.take(10)}")
return false
}
onLog("OTA: 文件校验通过")
return true
}
private fun installApk(uri: Uri): Boolean {
return runCatching {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
data = uri
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
putExtra(Intent.EXTRA_RETURN_RESULT, false)
}
intent.resolveActivity(appContext.packageManager)
?: throw IllegalStateException("no package installer activity")
appContext.startActivity(intent)
true
}.onFailure {
onLog("OTA: 安装 Intent 失败 ${it.message}")
}.getOrDefault(false)
}
private fun materializeInstallUri(sourceUri: Uri, fileName: String): Uri? {
return runCatching {
val otaDir = File(appContext.cacheDir, "ota").apply { mkdirs() }
val apkFile = File(otaDir, fileName.ifBlank { "ai-glasses-update.apk" })
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
FileOutputStream(apkFile, false).use { output ->
input.copyTo(output)
}
} ?: return null
FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.fileprovider",
apkFile
)
}.onFailure {
onLog("OTA: 准备安装包失败 ${it.message}")
}.getOrNull()
}
private fun formatBytes(value: Long): String {
if (value < 1024L) return "${value}B"
val kb = value / 1024.0
if (kb < 1024.0) return String.format("%.1fKB", kb)
val mb = kb / 1024.0
if (mb < 1024.0) return String.format("%.1fMB", mb)
val gb = mb / 1024.0
return String.format("%.2fGB", gb)
}
private fun reasonToText(reason: Int): String {
return when (reason) {
DownloadManager.ERROR_CANNOT_RESUME -> "CANNOT_RESUME"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "DEVICE_NOT_FOUND"
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "FILE_ALREADY_EXISTS"
DownloadManager.ERROR_FILE_ERROR -> "FILE_ERROR"
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP_DATA_ERROR"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "INSUFFICIENT_SPACE"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "TOO_MANY_REDIRECTS"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "UNHANDLED_HTTP_CODE"
DownloadManager.ERROR_UNKNOWN -> "UNKNOWN"
DownloadManager.PAUSED_QUEUED_FOR_WIFI -> "PAUSED_QUEUED_FOR_WIFI"
DownloadManager.PAUSED_WAITING_FOR_NETWORK -> "PAUSED_WAITING_FOR_NETWORK"
DownloadManager.PAUSED_WAITING_TO_RETRY -> "PAUSED_WAITING_TO_RETRY"
DownloadManager.PAUSED_UNKNOWN -> "PAUSED_UNKNOWN"
else -> "OTHER"
}
}
private fun statusToText(status: Int): String {
return when (status) {
DownloadManager.STATUS_PENDING -> "PENDING"
DownloadManager.STATUS_RUNNING -> "RUNNING"
DownloadManager.STATUS_PAUSED -> "PAUSED"
DownloadManager.STATUS_SUCCESSFUL -> "SUCCESSFUL"
DownloadManager.STATUS_FAILED -> "FAILED"
else -> "UNKNOWN"
}
}
private fun canInstallPackages(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.packageManager.canRequestPackageInstalls()
} else {
true
}
}
private fun openInstallPermissionSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${appContext.packageName}")
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
appContext.startActivity(intent)
}
}
private companion object {
const val PREFS_NAME = "ota_updater_prefs"
const val KEY_DOWNLOAD_ID = "download_id"
const val KEY_DOWNLOAD_URL = "download_url"
const val KEY_EXPECTED_SHA256 = "expected_sha256"
const val KEY_FILE_NAME = "file_name"
}
}

View File

@@ -1,3 +0,0 @@
<resources>
<string name="app_name">StoryForge AI</string>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="ota_cache"
path="ota/" />
</paths>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>

View File

@@ -1,6 +0,0 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
}

View File

@@ -1,5 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
android-app/gradlew vendored
View File

@@ -1,249 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@@ -1,92 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,19 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "AIGlassesApp"
include(":app")

View File

@@ -16,7 +16,6 @@
- 知识库、智能体、任务管理 - 知识库、智能体、任务管理
- 视频链接/上传视频/文本三类入口 - 视频链接/上传视频/文本三类入口
- 下载器、ffmpeg、whisper.cpp 风格的本地处理调用 - 下载器、ffmpeg、whisper.cpp 风格的本地处理调用
- Android OTA 查询/发布
### 2. 旧数据集运行链实际承担的功能 ### 2. 旧数据集运行链实际承担的功能
@@ -180,12 +179,11 @@
- `n8n` 工作流导出文件已纳入仓库 - `n8n` 工作流导出文件已纳入仓库
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖旧导入目录的临时 bind mount - `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖旧导入目录的临时 bind mount
- `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由 - `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数 - 曾混入本仓库的 `android-app/` 已确认来自独立 `AI Glasses` 工程叠加,现已从 StoryForge 主仓库边界中拆出,后续不再作为当前主工作区的一部分维护
- Android 工作区缺失的 `com.aiglasses.app.data` 数据层已从同源代码补回,当前 `./gradlew :app:compileDebugKotlin``:app:assembleDebug` 均已通过,并产出 `app-debug.apk`
## 当前主要风险 ## 当前主要风险
1. 小红书账号级内容源还未做真实平台验证 1. 小红书账号级内容源还未做真实平台验证
2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链 2. `douyin` public 直抓仍受反爬限制,但现在已经有“真实浏览器 + 人工登录 + 自动提取 + 回写现有工作台”的可落地协作链
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞 3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
4. Android 端目前已能完成 Debug APK 构建,但仍缺少真机安装和功能回归验证 4. Android / OTA 旧链路已拆出当前仓库,相关验证和发布不再属于 StoryForge 主线范围

View File

@@ -295,22 +295,18 @@ npm run capture -- \
- `http://127.0.0.1:8081/healthz` - `http://127.0.0.1:8081/healthz`
- `http://127.0.0.1:5670/healthz` - `http://127.0.0.1:5670/healthz`
## 11. Android 本地构建 ## 11. Android 说明
如果你要在本机重新打 Android 包: `android-app/` 已确认属于独立 `AI Glasses` 工程的叠加目录,现已从当前 StoryForge 主仓库拆出。
```bash 当前联调范围只包含:
cd /Users/kris/code/StoryForge-gitea/android-app
./gradlew :app:assembleDebug
```
当前已验证结果: - `collector-service`
- `n8n`
- `web/storyforge-web-v4`
- `scripts/douyin-browser-capture`
- `:app:compileDebugKotlin` 通过 如果后续需要维护 Android / OTA 链路,请转到独立仓库:
- `:app:assembleDebug` 通过
- APK 输出路径:
- `/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
补充说明: - Gitea`https://git.hyzq.site/krisolo/ai-glasses`
- 本机工作区:`/Users/kris/code/AI-glasses`
- 工作区根目录的 `.gitignore` 里保留了通用 `data/` 忽略规则,但已对 Android 源码目录 `android-app/app/src/main/java/com/aiglasses/app/data/` 做了白名单放行,避免误伤客户端代码

View File

@@ -10,8 +10,6 @@
- `user -> project -> assistant / knowledge base / job / content source` 数据模型 - `user -> project -> assistant / knowledge base / job / content source` 数据模型
- 文本 / 视频链接 / 上传视频 三类分析任务创建 - 文本 / 视频链接 / 上传视频 三类分析任务创建
- 内容源账号同步任务创建与子任务派发 - 内容源账号同步任务创建与子任务派发
- Android Explore 页已补上内容源账号同步入口
- Android `com.aiglasses.app.data` 数据层已补回,`compileDebugKotlin``assembleDebug` 已通过
- `n8n` 工作流导入、激活与触发接口 - `n8n` 工作流导入、激活与触发接口
- 本地下载器调用 - 本地下载器调用
- 本地 `ffmpeg` / `whisper` 风格入口封装 - 本地 `ffmpeg` / `whisper` 风格入口封装
@@ -46,7 +44,6 @@
- `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8` - `douyin` 对标关系:`dyrel_c8df266341e74237b99c880eb4b572d8`
- `huobao-upstream` 隔离 smoke 剧本:`drama_id=11` (`http://127.0.0.1:5681`) - `huobao-upstream` 隔离 smoke 剧本:`drama_id=11` (`http://127.0.0.1:5681`)
- `huobao-upstream` 隔离 smoke 启动脚本:`/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh` - `huobao-upstream` 隔离 smoke 启动脚本:`/Users/kris/code/huobao-drama-upstream/scripts/run_storyforge_smoke.sh`
- Android Debug APK`/Users/kris/code/StoryForge-gitea/android-app/app/build/outputs/apk/debug/app-debug.apk`
- `douyin` 浏览器采集最小 smoke`/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001` - `douyin` 浏览器采集最小 smoke`/tmp/storyforge-douyin-capture-smoke/2026-03-20T06-49-37.705Z-storyforge_test_001`
- `douyin` 控制台 smoke`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001` - `douyin` 控制台 smoke`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzplxp-cw0o7q/2026-03-20T14-24-13.174Z-storyforge_test_001`
- `douyin` 控制台提前继续回归 smoke`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001` - `douyin` 控制台提前继续回归 smoke`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel/run-mmyzshsp-c6vdhi/2026-03-20T14-26-27.792Z-storyforge_test_001`
@@ -59,7 +56,7 @@
- `douyin` 控制台点击流已可用,但它仍然依赖本机可打开 Chromium 的环境,不适合放进纯 Docker 容器内部跑 GUI - `douyin` 控制台点击流已可用,但它仍然依赖本机可打开 Chromium 的环境,不适合放进纯 Docker 容器内部跑 GUI
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user` - `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置 - `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
- Android Debug 包已可本地构建,但尚未完成真机安装验证 - Android / OTA 链路已拆回 `AI Glasses` 独立仓库,不再纳入当前 StoryForge MVP 范围
## 下一步优先级 ## 下一步优先级

View File

@@ -6,7 +6,8 @@
- `StoryForge``AI Glasses` 是两个独立项目,分别独立维护。 - `StoryForge``AI Glasses` 是两个独立项目,分别独立维护。
- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。 - 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。
- 后续在本仓库中看到的 `AI Glasses` 命名残留,应优先视为历史迁移残留或暂未完成的命名收口,不应直接推导为“需要删除 AI Glasses 项目代码” - `AI Glasses` 当前独立维护仓库为 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)
- 当前仓库已经移除混入的 `android-app/` 目录;历史提交中的 Android / `com.aiglasses.*` 痕迹只作为拆分审计证据保留。
## 当前仓库内属于 StoryForge 的主维护范围 ## 当前仓库内属于 StoryForge 的主维护范围
@@ -14,21 +15,15 @@
- `web/storyforge-web-v4/`StoryForge Web 工作台和前端壳。 - `web/storyforge-web-v4/`StoryForge Web 工作台和前端壳。
- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。 - `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。
- `n8n/`StoryForge 编排工作流导出与说明。 - `n8n/`StoryForge 编排工作流导出与说明。
- `android-app/`:当前 StoryForge Android 客户端入口。
- `deploy/`StoryForge 部署模板与网关配置。 - `deploy/`StoryForge 部署模板与网关配置。
- `docs/`StoryForge 审计、联调、实施与产品逻辑文档。 - `docs/`StoryForge 审计、联调、实施与产品逻辑文档。
- `docker-compose.yml``.env.example``scripts/start_business.sh``scripts/status_business.sh``scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。 - `docker-compose.yml``.env.example``scripts/start_business.sh``scripts/status_business.sh``scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。
## 需要特别注意的命名残留 ## 已拆出的独立项目边界
以下内容说明 Android 客户端曾沿用旧命名空间,但当前业务入口已经是 StoryForge - `AI Glasses` 的 Android / BLE / Baidu / AAR / OTA 代码不再属于当前 StoryForge 主仓库边界。
- 与其相关的当前维护仓库、分支、发布应在 `krisolo/ai-glasses` 中进行。
- `android-app/app/src/main/java/com/aiglasses/app/`Android 包名仍是 `com.aiglasses.app` - 若后续需要回看叠加来源,可参考 Git 历史中的 `acb1103``ac6a8a8``7070c3a``fe07a5f` 等提交,以及 [StoryForge / AI Glasses 拆分评估方案](./STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)
- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`:入口已经直接加载 `StoryForgeScreen``StoryForgeViewModel`
- `android-app/app/src/main/res/values/themes.xml`:主题名仍为 `Theme.AIGlasses`
- `android-app/app/build.gradle.kts`:构建命名空间仍与 `com.aiglasses.*` 保持一致。
这些文件目前应被视为 StoryForge Android 客户端的迁移残留,不属于“删除 AI Glasses 项目代码”的操作范围。若未来要统一命名,应作为独立重构任务推进,而不是在日常功能开发中顺手清除。
## 提交与同步边界 ## 提交与同步边界
@@ -43,5 +38,5 @@
- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。 - 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。
- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。 - n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。
- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate并拆出首批运行时模块。 - Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate并拆出首批运行时模块。
- Android 安全收口:会话加密存储、明文流量白名单、敏感输入遮罩、日志级别收紧 - 仓库边界收口:将混入的 `android-app/` 从 StoryForge 主仓库移出,并确认 `AI Glasses` 继续在独立 Gitea 仓库维护
- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。 - 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。

View File

@@ -0,0 +1,252 @@
# StoryForge / AI Glasses 拆分评估方案
执行状态2026-03-26
- 已确认独立仓库存在:`https://git.hyzq.site/krisolo/ai-glasses`
- 已确认本机独立工作区存在:`/Users/kris/code/AI-glasses`
- 当前评估方案已进入执行阶段:`StoryForge-gitea` 将移除混入的 `android-app/`
## 1. 结论摘要
当前仓库的问题更像是“项目导入时发生了目录叠加”,而不是后续开发过程中出现了随机数据错乱。
明确证据如下:
- Gitea 现有历史只有一个根提交:`acb1103`,日期为 `2026-03-14`
- 这个根提交从一开始就包含完整的 `android-app/` 子树。
-`android-app/` 子树内同时存在:
- `StoryForge` 相关界面与接口代码;
- 明显属于 `AI Glasses` 的包名、BLE、Baidu 实时能力、硬件依赖和 AAR。
因此,当前更合理的判断是:
- `StoryForge``AI Glasses` 原本是两个独立项目;
-`StoryForge-gitea` 建库或导入时,把一个带 `AI Glasses` Android 子项目的目录整体叠加进来了;
- 后续又在这个混合目录上继续写入了一部分 `StoryForge` Android 代码,导致边界越来越模糊。
## 2. 现状诊断
### 2.1 明显属于 StoryForge 的主干目录
这些目录整体上是当前 StoryForge 的核心交付面:
- `collector-service/`
- `web/storyforge-web-v4/`
- `scripts/douyin-browser-capture/`
- `n8n/`
- `deploy/`
- `docs/`
- `Common/`
- `docker-compose.yml`
- `.env.example`
### 2.2 明显带有 AI Glasses 叠加痕迹的区域
`android-app/` 是本仓库最明显的混合区,内部包含三类内容:
1. 明显偏 AI Glasses / 硬件链路的内容:
- `android-app/app/src/main/java/com/aiglasses/app/ble/BleManager.kt`
- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduConversationAgent.kt`
- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduRealtimeWsClient.kt`
- `android-app/app/src/main/java/com/aiglasses/app/software/BaiduVisualUploader.kt`
- `android-app/app/src/main/java/com/aiglasses/app/software/SoftwareConversationController.kt`
- `android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt`
- `android-app/app/libs/lib_agent-1.0.1.4.aar`
- `android-app/app/libs/brtc-3.5.0.1a.aar`
2. 明显是 StoryForge 业务,但写在旧命名空间里的内容:
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeApiService.kt`
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeModels.kt`
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt`
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt`
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt`
- `android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt`
- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`
3. 明显属于旧项目命名残留的工程设置:
- `android-app/settings.gradle.kts` 中的 `rootProject.name = "AIGlassesApp"`
- `android-app/app/build.gradle.kts` 中的 `namespace = "com.aiglasses.app"`
- `android-app/app/src/main/res/values/themes.xml` 中的 `Theme.AIGlasses`
- `android-app/app/src/main/AndroidManifest.xml` 当前仍引用 `Theme.AIGlasses`
### 2.3 Git 历史上的关键时间点
- `2026-03-14` `acb1103`
- Gitea 根提交。
- 从第一天就已带入 `android-app/``com.aiglasses.*`
- `2026-03-20 14:10` `ac6a8a8`
- 开始明显向 StoryForge Android UI / 交互继续推进。
- `2026-03-20 14:17` `7070c3a`
- 提交信息直接是 `restore android build path`,说明 Android 构建链被重新激活。
- `2026-03-22` `fe07a5f`
- 明确进入 `storyforge mobile v4 shell` 阶段。
结论是Gitea 历史里没有“完全纯净、完全不含 Android 叠加痕迹”的版本,但存在“尚未明显进入 APK 推进阶段”的较早切点。
## 3. 目标定义
基于当前产品节奏,推荐把拆分目标定义成:
- `StoryForge-gitea` 只保留 StoryForge 当前实际在推进的主线:
- Web
- Backend
- n8n orchestration
- Douyin browser capture
- deploy / docs / ops
- `AI Glasses` 相关 Android / BLE / Baidu / AAR / OTA 旧链路,移出当前仓库边界。
- 如果未来要做 StoryForge Mobile重新在一个干净边界内启动而不是继续沿用 `com.aiglasses.*` 的混合目录。
## 4. 拆分策略选项
### 方案 A按目录硬拆StoryForge 先回到 Web 主线
做法:
- 从当前 StoryForge 仓库中移除整个 `android-app/` 目录。
- 同步清理 README、docs、脚本中所有 Android/APK 主线描述。
- 保留 Web、Backend、n8n、browser capture、deploy、docs 作为 StoryForge 正式主干。
优点:
- 边界最清楚,最符合“此前一直在做 Web 版本”的项目认知。
- 能最快结束当前“两个项目目录叠加”的混乱状态。
- 后续所有开发决策都会更简单。
缺点:
- 当前 `android-app/storyforge/*` 里写过的一些 StoryForge 业务代码会一起被移出,需要单独存档。
适用判断:
- 如果当前项目目标就是 Web 优先、暂不做 APK这是最推荐方案。
### 方案 B保留 StoryForge Android 子集,拆掉 AI Glasses 硬件链
做法:
-`android-app/` 中只保留 `storyforge/*``MainActivity.kt`、必要的网络与 OTA 文件;
- 删除 `ble/``software/`、旧 `ui/MainViewModel.kt`、AAR、旧权限与旧命名
- 后续再把包名重构到 `com.storyforge.*`
优点:
- 保留了已写过的 StoryForge 移动端业务界面。
缺点:
- 仍要处理大量命名空间和依赖残留。
- 会继续占用当前 StoryForge 项目的精力。
- 和“你之前并没有打算做 APK”的事实不完全一致。
适用判断:
- 只有在你确认近期确实要保留 StoryForge Android 端时才值得做。
### 方案 C直接回滚到较早基线
候选点:
- `acb1103`:最早基线,但已经带着 Android 叠加目录。
- `1c539ab`:仍未明显进入 Android 壳推进,但已有少量 Android 接口同步。
优点:
- 操作简单。
缺点:
- 无法真正解决“根提交就已经叠加”的结构问题。
- 会回退掉后续大量有价值的 Web / backend / deploy 进展。
适用判断:
- 只适合做参考,不适合作为主方案。
## 5. 推荐方案
推荐采用 `方案 A按目录硬拆StoryForge 先回到 Web 主线`
原因:
- 它最符合当前产品事实:你确认之前的实际推进重点一直是 Web而不是 APK。
- 它最符合现有目录证据:`android-app/` 是混合最严重的区域,且根提交就已叠加。
- 它最符合后续治理成本:先把 StoryForge 主仓库边界收干净,后面要不要重建移动端,再单独决定。
## 6. 实施步骤
### 第 0 阶段:安全快照
- 基于当前 Gitea 状态打一个拆分前快照分支。
- 导出 `android-app/` 的完整目录快照,作为独立归档或后续 AI Glasses 仓库恢复源。
- 记录关键参考提交:
- `acb1103`
- `1c539ab`
- `ac6a8a8`
- `7070c3a`
- `fe07a5f`
### 第 1 阶段StoryForge 主仓库边界清理
- 从 StoryForge 仓库中移除整个 `android-app/`
- 清理以下入口中的 Android/APK 主线描述:
- `README.md`
- `docs/AUDIT_2026-03-18.md`
- `docs/MVP_STATUS_2026-03-18.md`
- `docs/LAN_E2E_GUIDE_2026-03-18.md`
- 其他出现 `compileDebugKotlin``assembleDebug``APK``com.aiglasses` 的说明文档
- 调整基线检查脚本,不再把 Android 编译当成 StoryForge 主仓库必检项。
### 第 2 阶段AI Glasses 资产外置
-`android-app/` 单独落到 AI Glasses 仓库或归档仓库。
- 在那个仓库中保留 `com.aiglasses.*`、BLE、Baidu、AAR、OTA 等原始工程语义。
### 第 3 阶段StoryForge 后续演进
- 当前仓库继续只推进:
- `collector-service/`
- `web/storyforge-web-v4/`
- `scripts/douyin-browser-capture/`
- `n8n/`
- `deploy/`
- `docs/`
- 若未来确实需要 StoryForge Mobile再开一个全新、干净的移动端工程不复用当前混合 Android 目录。
## 7. 风险与控制
### 风险 1误删仍有参考价值的 StoryForge Android 代码
控制:
- 在删除前先对 `android-app/` 做完整快照导出。
- 如果担心未来要参考 `storyforge/*` 子目录,可以单独保留一份只读归档。
### 风险 2文档和状态记录出现历史断层
控制:
- 不改历史提交。
- 仅在当前分支上明确标记“自本次拆分起StoryForge 主仓库不再承载 Android 主线”。
### 风险 3脚本和检查项仍假设存在 Android
控制:
- 统一核对:
- `README.md`
- `scripts/check_repo_baseline.sh`
- 任何引用 `./gradlew` 的脚本或文档
## 8. 最终建议
不要先回滚历史,也不要先做大规模重命名。
更稳妥的动作顺序应当是:
1. 先承认当前问题是“目录叠加”而不是“功能开发方向变化”。
2. 先把 `android-app/` 整体从 StoryForge 主仓库边界中拆出去。
3. 把 StoryForge 主仓库重新收敛成 Web / Backend / Orchestration 主线。
4. 最后再决定是否需要单独保留一个 StoryForge Mobile 项目。

View File

@@ -16,13 +16,13 @@ need_cmd node
cd "$ROOT" cd "$ROOT"
echo "[1/5] compile collector-service" echo "[1/4] compile collector-service"
python3 -m compileall collector-service/app >/dev/null python3 -m compileall collector-service/app >/dev/null
echo "[2/5] validate docker compose" echo "[2/4] validate docker compose"
docker compose config >/dev/null docker compose config >/dev/null
echo "[3/5] validate n8n workflows" echo "[3/4] validate n8n workflows"
python3 - <<'PY' python3 - <<'PY'
import json import json
import pathlib import pathlib
@@ -33,24 +33,10 @@ for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")):
print(f"workflow ok: {path.name}") print(f"workflow ok: {path.name}")
PY PY
echo "[4/5] validate web scripts" echo "[4/4] validate web scripts"
for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do
node --check "$file" node --check "$file"
done done
node --check scripts/douyin-browser-capture/control_panel.mjs node --check scripts/douyin-browser-capture/control_panel.mjs
if [ "${STORYFORGE_SKIP_ANDROID:-0}" = "1" ]; then
echo "[5/5] skip android compile (STORYFORGE_SKIP_ANDROID=1)"
else
if command -v java >/dev/null 2>&1; then
echo "[5/5] compile android debug kotlin"
(
cd android-app
./gradlew :app:compileDebugKotlin >/dev/null
)
else
echo "[5/5] skip android compile (java not installed)"
fi
fi
echo "baseline checks passed" echo "baseline checks passed"