chore: import storyforge baseline clean
This commit is contained in:
44
android-app/README.md
Normal file
44
android-app/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# AI Glasses Android App
|
||||
|
||||
Demo Android client for backend API validation and BLE integration scaffold.
|
||||
|
||||
## What is implemented
|
||||
|
||||
- Backend API calls:
|
||||
- `bind-confirm`
|
||||
- `create session`
|
||||
- `stop session`
|
||||
- `device status`
|
||||
- Compose UI for debug flow
|
||||
- Hichips BLE protocol manager:
|
||||
- service/char: `3D20(3D21/3D22/3D23)`, `5DC0(5DC1/5DC2/5DC3)`
|
||||
- packet codec: `HICH + Command + Index + Length + CRC16 + Data + IPSE`
|
||||
- handshake flow (`AG_CMD_HS_DEV_UUID` -> `AG_CMD_HS_APP_UUID` -> `AG_CMD_HS_DEV_INFO`)
|
||||
- wake-up audio uplink (`ASR_*` commands, audio from `5DC2`)
|
||||
- camera trigger (`AG_CMD_P_TAKE_START`) and thumbnail events
|
||||
- New "开始对话(硬件)" button:
|
||||
- BLE scan/connect -> handshake -> backend bind/create session
|
||||
- start wake-up audio stream + periodic camera capture
|
||||
- app reports aggregated audio/camera relay stats to backend events
|
||||
|
||||
## Default backend
|
||||
|
||||
The app is hardcoded to:
|
||||
|
||||
`http://test.hyzq.net`
|
||||
|
||||
## Build APK
|
||||
|
||||
Open this folder in Android Studio:
|
||||
|
||||
`/Users/kris/code/AI-glasses/android-app`
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
APK output:
|
||||
|
||||
`app/build/outputs/apk/debug/app-debug.apk`
|
||||
86
android-app/app/build.gradle.kts
Normal file
86
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,86 @@
|
||||
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://test.hyzq.net/storyforge\"")
|
||||
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.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.activity:activity-compose:1.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
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")
|
||||
}
|
||||
BIN
android-app/app/libs/brtc-3.5.0.1a.aar
Normal file
BIN
android-app/app/libs/brtc-3.5.0.1a.aar
Normal file
Binary file not shown.
BIN
android-app/app/libs/lib_agent-1.0.1.4.aar
Normal file
BIN
android-app/app/libs/lib_agent-1.0.1.4.aar
Normal file
Binary file not shown.
2
android-app/app/proguard-rules.pro
vendored
Normal file
2
android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Keep default for demo stage.
|
||||
|
||||
44
android-app/app/src/main/AndroidManifest.xml
Normal file
44
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?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.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<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:usesCleartextTraffic="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>
|
||||
@@ -0,0 +1,51 @@
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
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
|
||||
|
||||
@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
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@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 KnowledgeBaseDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val fastgpt_dataset_id: String? = null,
|
||||
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 description: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AssistantDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val system_prompt: String = "",
|
||||
val generation_goal: String = "",
|
||||
val knowledge_base_ids: List<String> = emptyList(),
|
||||
val fastgpt_app_key: String = "",
|
||||
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 fastgpt_app_key: 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 fastgpt_app_key: String? = null,
|
||||
val model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExploreVideoLinkRequest(
|
||||
val video_url: String,
|
||||
val title: 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 knowledge_base_id: String? = null,
|
||||
val assistant_id: String? = null,
|
||||
val analysis_model_profile_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JobDto(
|
||||
val id: String,
|
||||
val user_id: String,
|
||||
val assistant_id: String? = null,
|
||||
val knowledge_base_id: String,
|
||||
val source_type: String,
|
||||
val source_url: String? = null,
|
||||
val title: String,
|
||||
val language: String,
|
||||
val status: String,
|
||||
val transcript_text: String = "",
|
||||
val style_summary: String = "",
|
||||
val fastgpt_collection_id: String = "",
|
||||
val upload_status: String = "pending",
|
||||
val error: String = "",
|
||||
val artifacts: Map<String, String> = emptyMap(),
|
||||
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 fastgpt_collection_id: String = "",
|
||||
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 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
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
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 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 = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
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("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)) 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}$""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StoryForgeScreen(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
onPickVideo: () -> Unit,
|
||||
onInstallLatestUpdate: () -> Unit
|
||||
) {
|
||||
val heroBrush = Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF0B3C5D), Color(0xFF1F6E5F), Color(0xFFB97524))
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (state.isAuthenticated && state.isApproved) {
|
||||
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
|
||||
BottomTabItem(label = "探索", tab = StoryForgeTab.Explore, state = state, onSelect = vm::selectTab)
|
||||
BottomTabItem(label = "生产", tab = StoryForgeTab.Production, state = state, onSelect = vm::selectTab)
|
||||
BottomTabItem(label = "我的", tab = StoryForgeTab.Mine, state = state, onSelect = vm::selectTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
!state.isAuthenticated -> AuthScreen(state = state, vm = vm, heroBrush = heroBrush)
|
||||
!state.isApproved -> PendingApprovalScreen(state = state, vm = vm, heroBrush = heroBrush)
|
||||
else -> AppShell(
|
||||
state = state,
|
||||
vm = vm,
|
||||
heroBrush = heroBrush,
|
||||
onPickVideo = onPickVideo,
|
||||
onInstallLatestUpdate = onInstallLatestUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomTabItem(
|
||||
label: String,
|
||||
tab: StoryForgeTab,
|
||||
state: StoryForgeUiState,
|
||||
onSelect: (StoryForgeTab) -> Unit
|
||||
) {
|
||||
val selected = state.currentTab == tab
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clickable { onSelect(tab) }
|
||||
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = label.take(1), fontWeight = FontWeight.Bold)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthScreen(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
heroBrush: Brush
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(heroBrush)
|
||||
.padding(18.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("StoryForge AI", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
if (state.authMode == StoryForgeAuthMode.Login) "登录账号" else "注册新账号,提交后等待主管理员审批",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
ChoiceRow(
|
||||
options = listOf("登录" to (state.authMode == StoryForgeAuthMode.Login), "注册" to (state.authMode == StoryForgeAuthMode.Register)),
|
||||
onSelect = { label -> vm.setAuthMode(if (label == "登录") StoryForgeAuthMode.Login else StoryForgeAuthMode.Register) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = vm::updateUsername,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("账号") },
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = vm::updatePassword,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("密码") },
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() },
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(if (state.authMode == StoryForgeAuthMode.Login) "登录" else "注册")
|
||||
}
|
||||
}
|
||||
if (state.statusMessage.isNotBlank()) {
|
||||
Text(state.statusMessage, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (state.errorMessage.isNotBlank()) {
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingApprovalScreen(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
heroBrush: Brush
|
||||
) {
|
||||
val account = state.account
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
HeroCard(
|
||||
title = "等待审批",
|
||||
subtitle = "${account?.display_name ?: account?.username ?: "当前账号"} 已登录,但尚未通过主管理员审批。",
|
||||
heroBrush = heroBrush,
|
||||
badges = listOf(
|
||||
"审批状态:${account?.approval_status ?: "pending"}",
|
||||
if (state.resolvedIp.isNotBlank()) "已解析到 ${state.resolvedIp}" else ""
|
||||
).filter { it.isNotBlank() }
|
||||
)
|
||||
SectionCard(title = "当前说明", subtitle = state.statusMessage) {
|
||||
Text("新注册账号在主管理员通过前,无法访问探索、生产和知识库功能。")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = vm::refreshApprovalStatus, enabled = !state.busy) {
|
||||
Text("刷新审批状态")
|
||||
}
|
||||
OutlinedButton(onClick = vm::logout) {
|
||||
Text("退出登录")
|
||||
}
|
||||
}
|
||||
if (state.errorMessage.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppShell(
|
||||
state: StoryForgeUiState,
|
||||
vm: StoryForgeViewModel,
|
||||
heroBrush: Brush,
|
||||
onPickVideo: () -> Unit,
|
||||
onInstallLatestUpdate: () -> Unit
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
HeroCard(
|
||||
title = when (state.currentTab) {
|
||||
StoryForgeTab.Explore -> "探索素材"
|
||||
StoryForgeTab.Production -> "生产文案"
|
||||
StoryForgeTab.Mine -> "我的工作台"
|
||||
},
|
||||
subtitle = state.statusMessage,
|
||||
heroBrush = heroBrush,
|
||||
badges = listOf(
|
||||
state.account?.display_name ?: state.account?.username.orEmpty(),
|
||||
state.account?.role ?: "",
|
||||
if (state.resolvedIp.isNotBlank()) "IP ${state.resolvedIp}" else ""
|
||||
).filter { it.isNotBlank() }
|
||||
)
|
||||
StatusStrip(state = state, onRefresh = vm::refreshWorkspace)
|
||||
when (state.currentTab) {
|
||||
StoryForgeTab.Explore -> ExploreTab(state = state, vm = vm, onPickVideo = onPickVideo)
|
||||
StoryForgeTab.Production -> ProductionTab(state = state, vm = vm)
|
||||
StoryForgeTab.Mine -> MineTab(state = state, vm = vm, onInstallLatestUpdate = onInstallLatestUpdate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusStrip(state: StoryForgeUiState, onRefresh: () -> Unit) {
|
||||
SectionCard(title = "连接状态", subtitle = if (state.busy) "正在同步" else "已连接") {
|
||||
Text(
|
||||
text = if (state.originalHost.isNotBlank()) {
|
||||
"外网域名已解析为 ${state.resolvedIp},请求会携带 Host=${state.originalHost}"
|
||||
} else {
|
||||
"当前使用地址:${state.baseUrl}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(onClick = onRefresh) {
|
||||
Text("刷新")
|
||||
}
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
if (state.errorMessage.isNotBlank()) {
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPickVideo: () -> Unit) {
|
||||
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
|
||||
ChoiceRow(
|
||||
options = listOf(
|
||||
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
|
||||
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
|
||||
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
|
||||
),
|
||||
onSelect = { label ->
|
||||
vm.setExploreInputMode(
|
||||
when (label) {
|
||||
"视频链接" -> ExploreInputMode.VideoLink
|
||||
"上传视频" -> ExploreInputMode.UploadVideo
|
||||
else -> ExploreInputMode.Text
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
KnowledgeBaseSelector(state = state, onSelect = vm::selectKnowledgeBase)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AssistantSelector(state = state, onSelect = vm::selectAssistant)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "当前分析模型:${state.modelProfiles.firstOrNull { it.id == state.account?.preferred_analysis_model_id }?.name ?: "本机默认模型"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when (state.exploreInputMode) {
|
||||
ExploreInputMode.VideoLink -> {
|
||||
OutlinedTextField(
|
||||
value = state.videoUrl,
|
||||
onValueChange = vm::updateVideoUrl,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("短视频链接") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.videoTitle,
|
||||
onValueChange = vm::updateVideoTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("素材标题(可选)") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::submitVideoLink, enabled = !state.busy) {
|
||||
Text("提交视频链接")
|
||||
}
|
||||
}
|
||||
ExploreInputMode.UploadVideo -> {
|
||||
OutlinedTextField(
|
||||
value = state.videoTitle,
|
||||
onValueChange = vm::updateVideoTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("素材标题(可选)") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(onClick = onPickVideo) {
|
||||
Text(if (state.pickedVideoName.isBlank()) "选择视频文件" else "重新选择")
|
||||
}
|
||||
Text(
|
||||
text = if (state.pickedVideoName.isBlank()) "未选择文件" else state.pickedVideoName,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::submitUploadVideo, enabled = !state.busy && state.pickedVideoName.isNotBlank()) {
|
||||
Text("上传并开始学习")
|
||||
}
|
||||
}
|
||||
ExploreInputMode.Text -> {
|
||||
OutlinedTextField(
|
||||
value = state.textTitle,
|
||||
onValueChange = vm::updateTextTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("素材标题") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.textContent,
|
||||
onValueChange = vm::updateTextContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("素材文字") },
|
||||
minLines = 5
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::submitText, enabled = !state.busy) {
|
||||
Text("分析并沉淀到知识库")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.latestJob?.let { latestJob ->
|
||||
SectionCard(title = "最新任务", subtitle = latestJob.title) {
|
||||
KeyValueRow(label = "状态", value = latestJob.status)
|
||||
KeyValueRow(label = "上传状态", value = latestJob.upload_status)
|
||||
if (latestJob.transcript_text.isNotBlank()) {
|
||||
KeyValueBlock(label = "文本转写", value = latestJob.transcript_text)
|
||||
}
|
||||
if (latestJob.style_summary.isNotBlank()) {
|
||||
KeyValueBlock(label = "风格提炼", value = latestJob.style_summary)
|
||||
}
|
||||
if (latestJob.error.isNotBlank()) {
|
||||
Text(latestJob.error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.documents.isNotEmpty()) {
|
||||
SectionCard(title = "当前知识库素材", subtitle = "已经沉淀到所选知识库的文本样本") {
|
||||
state.documents.forEach { document ->
|
||||
MiniCard(title = document.title, subtitle = document.style_summary.ifBlank { document.transcript_text.take(100) })
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProductionTab(state: StoryForgeUiState, vm: StoryForgeViewModel) {
|
||||
SectionCard(title = "智能体列表", subtitle = "一个智能体默认关联一个知识库,也可以关联多个知识库") {
|
||||
ChoiceRow(
|
||||
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.assistants.firstOrNull { it.name == label }?.let { vm.selectAssistant(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = vm::startNewAssistant) {
|
||||
Text("新建智能体")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "编辑智能体", subtitle = "提示词由用户提供,可随时调整模型和知识库绑定") {
|
||||
OutlinedTextField(
|
||||
value = state.assistantName,
|
||||
onValueChange = vm::updateAssistantName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("智能体名称") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.assistantDescription,
|
||||
onValueChange = vm::updateAssistantDescription,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("智能体说明") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.assistantSystemPrompt,
|
||||
onValueChange = vm::updateAssistantSystemPrompt,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("系统提示词") },
|
||||
minLines = 5
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.assistantGenerationGoal,
|
||||
onValueChange = vm::updateAssistantGenerationGoal,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("生成目标") },
|
||||
minLines = 3
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("选择生成模型", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.modelProfiles.map { it.name to (state.assistantModelProfileId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.updateAssistantModelProfileId(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("选择要关联的知识库", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.knowledgeBases.map { it.name to state.selectedAssistantKnowledgeBaseIds.contains(it.id) },
|
||||
onSelect = { label ->
|
||||
state.knowledgeBases.firstOrNull { it.name == label }?.let { vm.toggleAssistantKnowledgeBase(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Button(onClick = vm::saveAssistant, enabled = !state.busy) {
|
||||
Text(if (state.assistantEditorId.isNullOrBlank()) "创建智能体" else "保存智能体配置")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "生成文案", subtitle = "选择智能体后,直接基于关联知识库输出文案") {
|
||||
OutlinedTextField(
|
||||
value = state.generationBrief,
|
||||
onValueChange = vm::updateGenerationBrief,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("文案需求") },
|
||||
minLines = 4
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.generationPlatform,
|
||||
onValueChange = vm::updateGenerationPlatform,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("平台") },
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.generationAudience,
|
||||
onValueChange = vm::updateGenerationAudience,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("目标受众") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.generationExtraRequirements,
|
||||
onValueChange = vm::updateGenerationExtraRequirements,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("额外要求") },
|
||||
minLines = 3
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::generateCopy, enabled = !state.generateBusy) {
|
||||
if (state.generateBusy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("开始生成")
|
||||
}
|
||||
}
|
||||
if (state.generationOutput.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
KeyValueBlock(label = "生成结果", value = state.generationOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstallLatestUpdate: () -> Unit) {
|
||||
SectionCard(title = "我的账号", subtitle = state.account?.display_name ?: state.account?.username.orEmpty()) {
|
||||
KeyValueRow(label = "用户名", value = state.account?.username ?: "-")
|
||||
KeyValueRow(label = "角色", value = state.account?.role ?: "-")
|
||||
KeyValueRow(label = "审批", value = state.account?.approval_status ?: "-")
|
||||
KeyValueRow(label = "Base URL", value = state.baseUrl)
|
||||
if (state.resolvedIp.isNotBlank()) {
|
||||
KeyValueRow(label = "解析 IP", value = state.resolvedIp)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = vm::logout) {
|
||||
Text("退出登录")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "分析模型", subtitle = "探索页默认使用这里选中的模型") {
|
||||
ChoiceRow(
|
||||
options = state.modelProfiles.map { it.name to (state.account?.preferred_analysis_model_id == it.id) },
|
||||
onSelect = { label ->
|
||||
state.modelProfiles.firstOrNull { it.name == label }?.let { vm.setPreferredModel(it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelName,
|
||||
onValueChange = vm::updateNewModelName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("模型别名") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelBaseUrl,
|
||||
onValueChange = vm::updateNewModelBaseUrl,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Base URL") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelModelName,
|
||||
onValueChange = vm::updateNewModelModelName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("模型名称") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.newModelApiKey,
|
||||
onValueChange = vm::updateNewModelApiKey,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("API Key") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::createModelProfile) {
|
||||
Text("保存为默认分析模型")
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "OTA 更新", subtitle = state.otaStatus.ifBlank { "检查新版本并执行安装" }) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = vm::checkForUpdates) {
|
||||
Text("检查更新")
|
||||
}
|
||||
OutlinedButton(onClick = onInstallLatestUpdate, enabled = state.otaInfo?.hasUpdate == true) {
|
||||
Text("安装最新版本")
|
||||
}
|
||||
}
|
||||
state.otaInfo?.let { ota ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
KeyValueRow(label = "最新版本", value = "${ota.latestVersionName} (${ota.latestVersionCode})")
|
||||
if (ota.releaseNotes.isNotBlank()) {
|
||||
KeyValueBlock(label = "更新说明", value = ota.releaseNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.account?.role == "super_admin") {
|
||||
SectionCard(title = "主管理员审批", subtitle = "新注册账号需要你审批后才能正常使用全部功能") {
|
||||
if (state.pendingAccounts.isEmpty()) {
|
||||
Text("当前没有待审批账号")
|
||||
} else {
|
||||
state.pendingAccounts.forEach { account ->
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(account.display_name, fontWeight = FontWeight.Bold)
|
||||
Text(account.username, style = MaterialTheme.typography.bodySmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = { vm.approveAccount(account.id) }) {
|
||||
Text("通过")
|
||||
}
|
||||
OutlinedButton(onClick = { vm.rejectAccount(account.id) }) {
|
||||
Text("拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "发布 OTA", subtitle = "主管理员可直接更新在线版本号和下载地址") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.publishVersionCode,
|
||||
onValueChange = vm::updatePublishVersionCode,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("VersionCode") },
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.publishMinSupportedCode,
|
||||
onValueChange = vm::updatePublishMinSupportedCode,
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("最低支持") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.publishVersionName,
|
||||
onValueChange = vm::updatePublishVersionName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("VersionName") },
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.publishApkUrl,
|
||||
onValueChange = vm::updatePublishApkUrl,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("APK 下载地址") },
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = state.publishNotes,
|
||||
onValueChange = vm::updatePublishNotes,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("更新说明") },
|
||||
minLines = 3
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("强制更新")
|
||||
Switch(checked = state.publishForceUpdate, onCheckedChange = vm::setPublishForceUpdate)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = vm::publishUpdate) {
|
||||
Text("发布 OTA")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "最近日志", subtitle = "用于确认审批、解析、任务和 OTA 状态") {
|
||||
state.timeline.forEach { item ->
|
||||
Text(item, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ChoiceRow(
|
||||
options: List<Pair<String, Boolean>>,
|
||||
onSelect: (String) -> Unit
|
||||
) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
options.forEach { (label, selected) ->
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(label) },
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnowledgeBaseSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
||||
Text("选择知识库", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.knowledgeBases.map { it.name to (state.selectedKnowledgeBaseId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.knowledgeBases.firstOrNull { it.name == label }?.let { onSelect(it.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantSelector(state: StoryForgeUiState, onSelect: (String) -> Unit) {
|
||||
Text("选择关联智能体", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChoiceRow(
|
||||
options = state.assistants.map { it.name to (state.selectedAssistantId == it.id) },
|
||||
onSelect = { label ->
|
||||
state.assistants.firstOrNull { it.name == label }?.let { onSelect(it.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroCard(title: String, subtitle: String, heroBrush: Brush, badges: List<String>) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(heroBrush)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineLarge, color = Color.White)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = Color(0xFFF8F5EF))
|
||||
if (badges.isNotEmpty()) {
|
||||
ChoiceRow(options = badges.map { it to true }, onSelect = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(title: String, subtitle: String, content: @Composable () -> Unit) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyValueRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(value, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyValueBlock(label: String, value: String) {
|
||||
Text(label, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), RoundedCornerShape(16.dp))
|
||||
.padding(14.dp)
|
||||
) {
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniCard(title: String, subtitle: String) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
Text(subtitle, maxLines = 4, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.aiglasses.app.storyforge
|
||||
|
||||
import android.content.Context
|
||||
import com.aiglasses.app.BuildConfig
|
||||
|
||||
data class SavedStoryForgeSession(
|
||||
val baseUrl: String,
|
||||
val token: String
|
||||
)
|
||||
|
||||
class StoryForgeSessionStore(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PREFS_NAME = "storyforge_session"
|
||||
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_IP_URL = "http://111.231.132.51:8081"
|
||||
}
|
||||
|
||||
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_IP_URL) -> BuildConfig.DEFAULT_STORYFORGE_BASE_URL
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
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 {
|
||||
Explore,
|
||||
Production,
|
||||
Mine
|
||||
}
|
||||
|
||||
enum class StoryForgeAuthMode {
|
||||
Login,
|
||||
Register
|
||||
}
|
||||
|
||||
enum class ExploreInputMode {
|
||||
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.Explore,
|
||||
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 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 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,
|
||||
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,
|
||||
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 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.Explore
|
||||
)
|
||||
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 ?: "发生未知错误"
|
||||
}
|
||||
1387
android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt
Normal file
1387
android-app/app/src/main/java/com/aiglasses/app/ui/MainViewModel.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
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(0xFF0E4B43),
|
||||
secondary = Color(0xFF9C6427),
|
||||
tertiary = Color(0xFF2A5B8A),
|
||||
background = Color(0xFFF7F3EC),
|
||||
surface = Color(0xFFFFFCF8),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFF1A1713),
|
||||
onSurface = Color(0xFF1A1713)
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF7FD6C7),
|
||||
secondary = Color(0xFFFFC27A),
|
||||
tertiary = Color(0xFF98C7FF),
|
||||
background = Color(0xFF101714),
|
||||
surface = Color(0xFF18211D),
|
||||
onPrimary = Color(0xFF062D29),
|
||||
onSecondary = Color(0xFF4B2B00),
|
||||
onBackground = Color(0xFFF0E8DB),
|
||||
onSurface = Color(0xFFF0E8DB)
|
||||
)
|
||||
|
||||
private val AppTypography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Serif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Serif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AIGlassesTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
3
android-app/app/src/main/res/values/strings.xml
Normal file
3
android-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">StoryForge AI</string>
|
||||
</resources>
|
||||
5
android-app/app/src/main/res/values/themes.xml
Normal file
5
android-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.AIGlasses" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
6
android-app/app/src/main/res/xml/file_paths.xml
Normal file
6
android-app/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="ota_cache"
|
||||
path="ota/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
|
||||
6
android-app/build.gradle.kts
Normal file
6
android-app/build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
||||
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
|
||||
}
|
||||
|
||||
5
android-app/gradle.properties
Normal file
5
android-app/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
|
||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
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
Executable file
249
android-app/gradlew
vendored
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/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" "$@"
|
||||
92
android-app/gradlew.bat
vendored
Normal file
92
android-app/gradlew.bat
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
@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
|
||||
19
android-app/settings.gradle.kts
Normal file
19
android-app/settings.gradle.kts
Normal file
@@ -0,0 +1,19 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "AIGlassesApp"
|
||||
include(":app")
|
||||
|
||||
Reference in New Issue
Block a user