feat: refactor input source handling and add audio input service

This commit is contained in:
Nedifinita
2025-12-05 17:01:42 +08:00
parent 7772112658
commit 99bc081583
13 changed files with 838 additions and 522 deletions

3
.gitignore vendored
View File

@@ -164,4 +164,5 @@ fabric.properties
!/gradle/wrapper/gradle-wrapper.jar !/gradle/wrapper/gradle-wrapper.jar
macos/Flutter/ephemeral/flutter_export_environment.sh macos/Flutter/ephemeral/flutter_export_environment.sh
macos/Flutter/ephemeral/Flutter-Generated.xcconfig macos/Flutter/ephemeral/Flutter-Generated.xcconfig
*.py *.py
PDW

View File

@@ -1,12 +1,8 @@
# LBJ_Console # LBJ_Console
LBJ Console 是一个应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括: LBJ Console 是一个应用程序,用于接收并显示列车预警消息,功能包括:
- 接收列车预警消息,支持可选的手机推送通知 应用程序支持从 SX1276_Receive_LBJ 获取 BLE 预警数据,或直接连接 RTL-TCP 服务器从 RTL-SDR 接收预警消息。在可视化方面,软件能够在地图上标注预警消息的 GPS 位置,并支持绘制指定列车的运行轨迹。此外,程序内置了机车数据文件,可根据数据内容匹配并显示机车配属、机车类型以及车次类型
- 监控指定列车的轨迹,在地图上显示。
- 在地图上显示预警消息的 GPS 信息。
- 基于内置数据文件显示机车配属,机车类型和车次类型。
- 连接 RTL-TCP 服务器获取预警消息。
[android](https://github.com/undef-i/LBJ_Console/tree/android) 分支包含项目早期基于 Android 平台的实现代码,已实现基本功能,现已停止开发。 [android](https://github.com/undef-i/LBJ_Console/tree/android) 分支包含项目早期基于 Android 平台的实现代码,已实现基本功能,现已停止开发。
@@ -26,6 +22,7 @@ LBJ Console 依赖以下数据文件,位于 `assets` 目录,用于支持机
- 集成 ESP-Touch 协议,实现设备 WiFi 凭证的配置。 - 集成 ESP-Touch 协议,实现设备 WiFi 凭证的配置。
- 从设备端拉取历史数据记录。 - 从设备端拉取历史数据记录。
- 从音频流解析预警消息。
# 致谢 # 致谢

View File

@@ -1,5 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/>
@@ -31,10 +34,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
@@ -44,13 +43,10 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- 前台服务配置 -->
<service <service
android:name="id.flutter.flutter_background_service.BackgroundService" android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="connectedDevice|dataSync" android:foregroundServiceType="connectedDevice|dataSync"
@@ -59,15 +55,10 @@
android:enabled="true" android:enabled="true"
tools:replace="android:exported"/> tools:replace="android:exported"/>
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -14,17 +14,29 @@ uint32_t bits;
uint32_t code_words[PAGERDEMOD_BATCH_WORDS]; uint32_t code_words[PAGERDEMOD_BATCH_WORDS];
bool code_words_bch_error[PAGERDEMOD_BATCH_WORDS]; bool code_words_bch_error[PAGERDEMOD_BATCH_WORDS];
static bool hysteresis_state = false;
static bool dsp_initialized = false;
std::string numeric_msg, alpha_msg; std::string numeric_msg, alpha_msg;
int function_bits; int function_bits;
uint32_t address; uint32_t address;
uint32_t alpha_bit_buffer; // Bit buffer to 7-bit chars spread across codewords uint32_t alpha_bit_buffer;
int alpha_bit_buffer_bits; // Count of bits in alpha_bit_buffer int alpha_bit_buffer_bits;
int parity_errors; // Count of parity errors in current message int parity_errors;
int bch_errors; // Count of BCH errors in current message int bch_errors;
int batch_num; // Count of batches in current transmission int batch_num;
double magsqRaw; double magsqRaw;
void ensureDSPInitialized() {
if (dsp_initialized) return;
lowpassBaud.create(301, SAMPLE_RATE, BAUD_RATE * 5.0f);
phaseDiscri.setFMScaling(SAMPLE_RATE / (2.0f * DEVIATION));
dsp_initialized = true;
}
int pop_cnt(uint32_t cw) int pop_cnt(uint32_t cw)
{ {
int cnt = 0; int cnt = 0;
@@ -39,10 +51,9 @@ int pop_cnt(uint32_t cw)
uint32_t bchEncode(const uint32_t cw) uint32_t bchEncode(const uint32_t cw)
{ {
uint32_t bit = 0; uint32_t bit = 0;
uint32_t localCW = cw & 0xFFFFF800; // Mask off BCH parity and even parity bits uint32_t localCW = cw & 0xFFFFF800;
uint32_t cwE = localCW; uint32_t cwE = localCW;
// Calculate BCH bits
for (bit = 1; bit <= 21; bit++) for (bit = 1; bit <= 21; bit++)
{ {
if (cwE & 0x80000000) if (cwE & 0x80000000)
@@ -56,38 +67,28 @@ uint32_t bchEncode(const uint32_t cw)
return localCW; return localCW;
} }
// Use BCH decoding to try to fix any bit errors
// Returns true if able to be decode/repair successful
// See: https://www.eevblog.com/forum/microcontrollers/practical-guides-to-bch-fec/
bool bchDecode(const uint32_t cw, uint32_t &correctedCW) bool bchDecode(const uint32_t cw, uint32_t &correctedCW)
{ {
// Calculate syndrome
// We do this by recalculating the BCH parity bits and XORing them against the received ones
uint32_t syndrome = ((bchEncode(cw) ^ cw) >> 1) & 0x3FF; uint32_t syndrome = ((bchEncode(cw) ^ cw) >> 1) & 0x3FF;
if (syndrome == 0) if (syndrome == 0)
{ {
// Syndrome of zero indicates no repair required
correctedCW = cw; correctedCW = cw;
return true; return true;
} }
// Meggitt decoder
uint32_t result = 0; uint32_t result = 0;
uint32_t damagedCW = cw; uint32_t damagedCW = cw;
// Calculate BCH bits
for (uint32_t xbit = 0; xbit < 31; xbit++) for (uint32_t xbit = 0; xbit < 31; xbit++)
{ {
// Produce the next corrected bit in the high bit of the result
result <<= 1; result <<= 1;
if ((syndrome == 0x3B4) || // 0x3B4: Syndrome when a single error is detected in the MSB if ((syndrome == 0x3B4) ||
(syndrome == 0x26E) || // 0x26E: Two adjacent errors (syndrome == 0x26E) ||
(syndrome == 0x359) || // 0x359: Two errors, one OK bit between (syndrome == 0x359) ||
(syndrome == 0x076) || // 0x076: Two errors, two OK bits between (syndrome == 0x076) ||
(syndrome == 0x255) || // 0x255: Two errors, three OK bits between (syndrome == 0x255) ||
(syndrome == 0x0F0) || // 0x0F0: Two errors, four OK bits between (syndrome == 0x0F0) ||
(syndrome == 0x216) || (syndrome == 0x216) ||
(syndrome == 0x365) || (syndrome == 0x365) ||
(syndrome == 0x068) || (syndrome == 0x068) ||
@@ -114,36 +115,29 @@ bool bchDecode(const uint32_t cw, uint32_t &correctedCW)
(syndrome == 0x3B6) || (syndrome == 0x3B6) ||
(syndrome == 0x3B5)) (syndrome == 0x3B5))
{ {
// Syndrome matches an error in the MSB
// Correct that error and adjust the syndrome to account for it
syndrome ^= 0x3B4; syndrome ^= 0x3B4;
result |= (~damagedCW & 0x80000000) >> 30; result |= (~damagedCW & 0x80000000) >> 30;
} }
else else
{ {
// No error
result |= (damagedCW & 0x80000000) >> 30; result |= (damagedCW & 0x80000000) >> 30;
} }
damagedCW <<= 1; damagedCW <<= 1;
// Handle syndrome shift register feedback
if (syndrome & 0x200) if (syndrome & 0x200)
{ {
syndrome <<= 1; syndrome <<= 1;
syndrome ^= 0x769; // 0x769 = POCSAG generator polynomial -- x^10 + x^9 + x^8 + x^6 + x^5 + x^3 + 1 syndrome ^= 0x769;
} }
else else
{ {
syndrome <<= 1; syndrome <<= 1;
} }
// Mask off bits which fall off the end of the syndrome shift register
syndrome &= 0x3FF; syndrome &= 0x3FF;
} }
// Check if error correction was successful
if (syndrome != 0) if (syndrome != 0)
{ {
// Syndrome nonzero at end indicates uncorrectable errors
correctedCW = cw; correctedCW = cw;
return false; return false;
} }
@@ -162,13 +156,11 @@ int xorBits(uint32_t word, int firstBit, int lastBit)
return x; return x;
} }
// Check for even parity
bool evenParity(uint32_t word, int firstBit, int lastBit, int parityBit) bool evenParity(uint32_t word, int firstBit, int lastBit, int parityBit)
{ {
return xorBits(word, firstBit, lastBit) == parityBit; return xorBits(word, firstBit, lastBit) == parityBit;
} }
// Reverse order of bits
uint32_t reverse(uint32_t x) uint32_t reverse(uint32_t x)
{ {
x = (((x & 0xaaaaaaaa) >> 1) | ((x & 0x55555555) << 1)); x = (((x & 0xaaaaaaaa) >> 1) | ((x & 0x55555555) << 1));
@@ -178,10 +170,6 @@ uint32_t reverse(uint32_t x)
return ((x >> 16) | (x << 16)); return ((x >> 16) | (x << 16));
} }
// Decode a batch of codewords to addresses and messages
// Messages may be spreadout over multiple batches
// https://www.itu.int/dms_pubrec/itu-r/rec/m/R-REC-M.584-1-198607-S!!PDF-E.pdf
// https://www.itu.int/dms_pubrec/itu-r/rec/m/R-REC-M.584-2-199711-I!!PDF-E.pdf
void decodeBatch() void decodeBatch()
{ {
int i = 1; int i = 1;
@@ -190,17 +178,13 @@ void decodeBatch()
for (int word = 0; word < PAGERDEMOD_CODEWORDS_PER_FRAME; word++) for (int word = 0; word < PAGERDEMOD_CODEWORDS_PER_FRAME; word++)
{ {
bool addressCodeWord = ((code_words[i] >> 31) & 1) == 0; bool addressCodeWord = ((code_words[i] >> 31) & 1) == 0;
// Check parity bit
bool parityError = !evenParity(code_words[i], 1, 31, code_words[i] & 0x1); bool parityError = !evenParity(code_words[i], 1, 31, code_words[i] & 0x1);
if (code_words[i] == PAGERDEMOD_POCSAG_IDLECODE) if (code_words[i] == PAGERDEMOD_POCSAG_IDLECODE)
{ {
// Idle
} }
else if (addressCodeWord) else if (addressCodeWord)
{ {
// Address
function_bits = (code_words[i] >> 11) & 0x3; function_bits = (code_words[i] >> 11) & 0x3;
int addressBits = (code_words[i] >> 13) & 0x3ffff; int addressBits = (code_words[i] >> 13) & 0x3ffff;
address = (addressBits << 3) | frame; address = (addressBits << 3) | frame;
@@ -213,44 +197,30 @@ void decodeBatch()
} }
else else
{ {
// Message - decode as both numeric and ASCII - not all operators use functionBits to indidcate encoding
int messageBits = (code_words[i] >> 11) & 0xfffff; int messageBits = (code_words[i] >> 11) & 0xfffff;
if (parityError) if (parityError) parity_errors++;
{ if (code_words_bch_error[i]) bch_errors++;
parity_errors++;
}
if (code_words_bch_error[i])
{
bch_errors++;
}
// Numeric format
for (int j = 16; j >= 0; j -= 4) for (int j = 16; j >= 0; j -= 4)
{ {
uint32_t numericBits = (messageBits >> j) & 0xf; uint32_t numericBits = (messageBits >> j) & 0xf;
numericBits = reverse(numericBits) >> (32 - 4); numericBits = reverse(numericBits) >> (32 - 4);
// Spec has 0xa as 'spare', but other decoders treat is as .
const char numericChars[] = { const char numericChars[] = {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', 'U', ' ', '-', ')', '('}; '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', 'U', ' ', '-', ')', '('};
char numericChar = numericChars[numericBits]; char numericChar = numericChars[numericBits];
numeric_msg.push_back(numericChar); numeric_msg.push_back(numericChar);
} }
// 7-bit ASCII alpnanumeric format
alpha_bit_buffer = (alpha_bit_buffer << 20) | messageBits; alpha_bit_buffer = (alpha_bit_buffer << 20) | messageBits;
alpha_bit_buffer_bits += 20; alpha_bit_buffer_bits += 20;
while (alpha_bit_buffer_bits >= 7) while (alpha_bit_buffer_bits >= 7)
{ {
// Extract next 7-bit character from bit buffer
char c = (alpha_bit_buffer >> (alpha_bit_buffer_bits - 7)) & 0x7f; char c = (alpha_bit_buffer >> (alpha_bit_buffer_bits - 7)) & 0x7f;
// Reverse bit ordering
c = reverse(c) >> (32 - 7); c = reverse(c) >> (32 - 7);
// Add to received message string (excluding, null, end of text, end ot transmission)
if (c != 0 && c != 0x3 && c != 0x4) if (c != 0 && c != 0x3 && c != 0x4)
{ {
alpha_msg.push_back(c); alpha_msg.push_back(c);
} }
// Remove from bit buffer
alpha_bit_buffer_bits -= 7; alpha_bit_buffer_bits -= 7;
if (alpha_bit_buffer_bits == 0) if (alpha_bit_buffer_bits == 0)
{ {
@@ -262,25 +232,16 @@ void decodeBatch()
} }
} }
} }
// Move to next codeword
i++; i++;
} }
} }
} }
void processOneSample(int8_t i, int8_t q) void processBasebandSample(double sample)
{ {
float fi = ((float)i) / 128.0f; ensureDSPInitialized();
float fq = ((float)q) / 128.0f;
std::complex<float> iq(fi, fq); double filt = lowpassBaud.filter(sample);
float deviation;
double fmDemod = phaseDiscri.phaseDiscriminatorDelta(iq, magsqRaw, deviation);
// printf("fmDemod: %.3f\n", fmDemod);
double filt = lowpassBaud.filter(fmDemod);
if (!got_SC) if (!got_SC)
{ {
@@ -288,54 +249,49 @@ void processOneSample(int8_t i, int8_t q)
dc_offset = preambleMovingAverage.asDouble(); dc_offset = preambleMovingAverage.asDouble();
} }
bool data = (filt - dc_offset) >= 0.0; double sample_val = filt - dc_offset;
// printf("filt - dc: %.3f\n", filt - dc_offset); double threshold = 0.05;
if (sample_val > threshold)
{
hysteresis_state = true;
}
else if (sample_val < -threshold)
{
hysteresis_state = false;
}
bool data = hysteresis_state;
if (data != prev_data) if (data != prev_data)
{ {
sync_cnt = SAMPLES_PER_SYMBOL / 2; // reset sync_cnt = SAMPLES_PER_SYMBOL / 2;
} }
else else
{ {
sync_cnt--; // wait until next bit's midpoint sync_cnt--;
if (sync_cnt <= 0) if (sync_cnt <= 0)
{ {
if (bit_inverted) if (bit_inverted) data_bit = data;
{ else data_bit = !data;
data_bit = data;
}
else
{
data_bit = !data;
}
// printf("%d", data_bit);
bits = (bits << 1) | data_bit; bits = (bits << 1) | data_bit;
bit_cnt++; bit_cnt++;
if (bit_cnt > 32) if (bit_cnt > 32) bit_cnt = 32;
{
bit_cnt = 32;
}
if (bit_cnt == 32 && !got_SC) if (bit_cnt == 32 && !got_SC)
{ {
// printf("pop count: %d\n", pop_cnt(bits ^ POCSAG_SYNCCODE));
// printf("pop count inv: %d\n", pop_cnt(bits ^ POCSAG_SYNCCODE_INV));
if (bits == POCSAG_SYNCCODE) if (bits == POCSAG_SYNCCODE)
{ {
got_SC = true; got_SC = true;
bit_inverted = false; bit_inverted = false;
printf("\nSync code found\n");
} }
else if (bits == POCSAG_SYNCCODE_INV) else if (bits == POCSAG_SYNCCODE_INV)
{ {
got_SC = true; got_SC = true;
bit_inverted = true; bit_inverted = true;
printf("\nSync code found\n");
} }
else if (pop_cnt(bits ^ POCSAG_SYNCCODE) <= 3) else if (pop_cnt(bits ^ POCSAG_SYNCCODE) <= 3)
{ {
@@ -344,9 +300,7 @@ void processOneSample(int8_t i, int8_t q)
{ {
got_SC = true; got_SC = true;
bit_inverted = false; bit_inverted = false;
printf("\nSync code found\n");
} }
// else printf("\nSync code not found\n");
} }
else if (pop_cnt(bits ^ POCSAG_SYNCCODE_INV) <= 3) else if (pop_cnt(bits ^ POCSAG_SYNCCODE_INV) <= 3)
{ {
@@ -355,9 +309,7 @@ void processOneSample(int8_t i, int8_t q)
{ {
got_SC = true; got_SC = true;
bit_inverted = true; bit_inverted = true;
printf("\nSync code found\n");
} }
// else printf("\nSync code not found\n");
} }
if (got_SC) if (got_SC)
@@ -394,7 +346,6 @@ void processOneSample(int8_t i, int8_t q)
if (address > 0 && !numeric_msg.empty()) if (address > 0 && !numeric_msg.empty())
{ {
is_message_ready = true; is_message_ready = true;
printf("Addr: %d | Numeric: %s | Alpha: %s\n", address, numeric_msg.c_str(), alpha_msg.c_str());
} }
else else
{ {
@@ -408,3 +359,16 @@ void processOneSample(int8_t i, int8_t q)
prev_data = data; prev_data = data;
} }
void processOneSample(int8_t i, int8_t q)
{
float fi = ((float)i) / 128.0f;
float fq = ((float)q) / 128.0f;
std::complex<float> iq(fi, fq);
float deviation;
double fmDemod = phaseDiscri.phaseDiscriminatorDelta(iq, magsqRaw, deviation);
processBasebandSample(fmDemod);
}

View File

@@ -34,6 +34,8 @@ extern Lowpass<double> lowpassBaud;
extern MovingAverageUtil<double, double, 2048> preambleMovingAverage; extern MovingAverageUtil<double, double, 2048> preambleMovingAverage;
extern double magsqRaw; extern double magsqRaw;
void ensureDSPInitialized();
void processOneSample(int8_t i, int8_t q); void processOneSample(int8_t i, int8_t q);
void processBasebandSample(double sample);
#endif #endif

View File

@@ -8,6 +8,8 @@
#include <chrono> #include <chrono>
#include <unistd.h> #include <unistd.h>
#include <arpa/inet.h> #include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h> #include <fcntl.h>
#include <android/log.h> #include <android/log.h>
#include <errno.h> #include <errno.h>
@@ -84,6 +86,40 @@ Java_org_noxylva_lbjconsole_flutter_RtlTcpChannelHandler_startClientAsync(
env->ReleaseStringUTFChars(port_, portStr); env->ReleaseStringUTFChars(port_, portStr);
} }
extern "C" JNIEXPORT void JNICALL
Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_nativePushAudio(
JNIEnv *env, jobject thiz, jshortArray audioData, jint size) {
ensureDSPInitialized();
jshort *samples = env->GetShortArrayElements(audioData, NULL);
std::lock_guard<std::mutex> demodLock(demodDataMutex);
for (int i = 0; i < size; i++) {
double sample = (double)samples[i] / 32768.0;
sample *= 5.0;
processBasebandSample(sample);
}
env->ReleaseShortArrayElements(audioData, samples, 0);
if (is_message_ready) {
std::ostringstream ss;
std::lock_guard<std::mutex> msgLock(msgMutex);
std::string message_content = alpha_msg.empty() ? numeric_msg : alpha_msg;
ss << "[MSG]" << address << "|" << function_bits << "|" << message_content;
messageBuffer.push_back(ss.str());
is_message_ready = false;
numeric_msg.clear();
alpha_msg.clear();
}
}
extern "C" JNIEXPORT jdouble JNICALL extern "C" JNIEXPORT jdouble JNICALL
Java_org_noxylva_lbjconsole_flutter_RtlTcpChannelHandler_getSignalStrength(JNIEnv *, jobject) Java_org_noxylva_lbjconsole_flutter_RtlTcpChannelHandler_getSignalStrength(JNIEnv *, jobject)
{ {
@@ -171,8 +207,8 @@ void clientThread(std::string host, int port)
goto cleanup; goto cleanup;
} }
lowpassBaud.create(301, SAMPLE_RATE, BAUD_RATE * 5.0f); ensureDSPInitialized();
phaseDiscri.setFMScaling(SAMPLE_RATE / (2.0f * DEVIATION));
sockfd_atomic.store(localSockfd); sockfd_atomic.store(localSockfd);
{ {
std::lock_guard<std::mutex> lock(msgMutex); std::lock_guard<std::mutex> lock(msgMutex);

View File

@@ -0,0 +1,126 @@
package org.noxylva.lbjconsole.flutter
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.atomic.AtomicBoolean
class AudioInputHandler(private val context: Context) : MethodChannel.MethodCallHandler {
private var audioRecord: AudioRecord? = null
private val isRecording = AtomicBoolean(false)
private var recordingThread: Thread? = null
private val sampleRate = 48000
private val bufferSize = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
) * 2
companion object {
private const val CHANNEL = "org.noxylva.lbjconsole/audio_input"
private const val TAG = "AudioInputHandler"
fun registerWith(flutterEngine: FlutterEngine, context: Context) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel.setMethodCallHandler(AudioInputHandler(context))
}
}
private external fun nativePushAudio(data: ShortArray, size: Int)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> {
if (startRecording()) {
result.success(null)
} else {
result.error("AUDIO_ERROR", "Failed to start audio recording", null)
}
}
"stop" -> {
stopRecording()
result.success(null)
}
else -> result.notImplemented()
}
}
private fun startRecording(): Boolean {
if (isRecording.get()) return true
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
Log.e(TAG, "Permission not granted")
return false
}
try {
val audioSource = MediaRecorder.AudioSource.UNPROCESSED
audioRecord = AudioRecord(
audioSource,
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
)
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord init failed")
return false
}
audioRecord?.startRecording()
isRecording.set(true)
recordingThread = Thread {
val buffer = ShortArray(bufferSize)
while (isRecording.get()) {
val readSize = audioRecord?.read(buffer, 0, buffer.size) ?: 0
if (readSize > 0) {
nativePushAudio(buffer, readSize)
}
}
}
recordingThread?.priority = Thread.MAX_PRIORITY
recordingThread?.start()
return true
} catch (e: Exception) {
Log.e(TAG, "Start recording exception", e)
stopRecording()
return false
}
}
private fun stopRecording() {
isRecording.set(false)
try {
recordingThread?.join(1000)
} catch (e: InterruptedException) {
e.printStackTrace()
}
try {
if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
audioRecord?.stop()
}
audioRecord?.release()
} catch (e: Exception) {
Log.e(TAG, "Stop recording exception", e)
}
audioRecord = null
recordingThread = null
}
}

View File

@@ -7,5 +7,6 @@ class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
RtlTcpChannelHandler.registerWith(flutterEngine) RtlTcpChannelHandler.registerWith(flutterEngine)
AudioInputHandler.registerWith(flutterEngine, applicationContext)
} }
} }

View File

@@ -7,13 +7,27 @@ allprojects {
} }
rootProject.buildDir = "../build" rootProject.buildDir = "../build"
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }
subprojects { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }
subprojects {
if (project.name != "app") {
project.afterEvaluate {
if (project.hasProperty("android")) {
project.android {
compileSdk 36
}
}
}
}
}
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@@ -13,15 +13,15 @@ import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/notification_service.dart'; import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/services/background_service.dart'; import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/services/rtl_tcp_service.dart'; import 'package:lbjconsole/services/rtl_tcp_service.dart';
import 'package:lbjconsole/services/audio_input_service.dart';
import 'package:lbjconsole/themes/app_theme.dart'; import 'package:lbjconsole/themes/app_theme.dart';
import 'dart:convert';
class _ConnectionStatusWidget extends StatefulWidget { class _ConnectionStatusWidget extends StatefulWidget {
final BLEService bleService; final BLEService bleService;
final RtlTcpService rtlTcpService; final RtlTcpService rtlTcpService;
final DateTime? lastReceivedTime; final DateTime? lastReceivedTime;
final DateTime? rtlTcpLastReceivedTime; final DateTime? rtlTcpLastReceivedTime;
final bool rtlTcpEnabled; final InputSource inputSource;
final bool rtlTcpConnected; final bool rtlTcpConnected;
const _ConnectionStatusWidget({ const _ConnectionStatusWidget({
@@ -29,7 +29,7 @@ class _ConnectionStatusWidget extends StatefulWidget {
required this.rtlTcpService, required this.rtlTcpService,
required this.lastReceivedTime, required this.lastReceivedTime,
required this.rtlTcpLastReceivedTime, required this.rtlTcpLastReceivedTime,
required this.rtlTcpEnabled, required this.inputSource,
required this.rtlTcpConnected, required this.rtlTcpConnected,
}); });
@@ -59,6 +59,15 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
_deviceStatus = widget.bleService.deviceStatus; _deviceStatus = widget.bleService.deviceStatus;
} }
@override
void didUpdateWidget(covariant _ConnectionStatusWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.inputSource != widget.inputSource ||
oldWidget.rtlTcpConnected != widget.rtlTcpConnected) {
setState(() {});
}
}
@override @override
void dispose() { void dispose() {
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
@@ -67,18 +76,31 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isRtlTcpMode = widget.rtlTcpEnabled; bool isConnected;
final rtlTcpConnected = widget.rtlTcpConnected; Color statusColor;
String statusText;
final isConnected = isRtlTcpMode ? rtlTcpConnected : _isConnected; DateTime? displayTime;
final statusColor = isRtlTcpMode
? (rtlTcpConnected ? Colors.green : Colors.red) switch (widget.inputSource) {
: (_isConnected ? Colors.green : Colors.red); case InputSource.rtlTcp:
final statusText = isRtlTcpMode isConnected = widget.rtlTcpConnected;
? (rtlTcpConnected ? '已连接' : '未连接') statusColor = isConnected ? Colors.green : Colors.red;
: _deviceStatus; statusText = isConnected ? '已连接' : '未连接';
displayTime = widget.rtlTcpLastReceivedTime;
final lastReceivedTime = isRtlTcpMode ? widget.rtlTcpLastReceivedTime : widget.lastReceivedTime; break;
case InputSource.audioInput:
isConnected = AudioInputService().isListening;
statusColor = isConnected ? Colors.green : Colors.red;
statusText = isConnected ? '监听中' : '已停止';
displayTime = widget.rtlTcpLastReceivedTime ?? widget.lastReceivedTime;
break;
case InputSource.bluetooth:
isConnected = _isConnected;
statusColor = isConnected ? Colors.green : Colors.red;
statusText = _deviceStatus;
displayTime = widget.lastReceivedTime;
break;
}
return Row( return Row(
children: [ children: [
@@ -86,12 +108,12 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (lastReceivedTime == null || !isConnected) ...[ if (displayTime == null || !isConnected) ...[
Text(statusText, Text(statusText,
style: const TextStyle(color: Colors.white70, fontSize: 12)), style: const TextStyle(color: Colors.white70, fontSize: 12)),
], ],
_LastReceivedTimeWidget( _LastReceivedTimeWidget(
lastReceivedTime: lastReceivedTime, lastReceivedTime: displayTime,
isConnected: isConnected, isConnected: isConnected,
), ),
], ],
@@ -215,7 +237,9 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
DateTime? _lastReceivedTime; DateTime? _lastReceivedTime;
DateTime? _rtlTcpLastReceivedTime; DateTime? _rtlTcpLastReceivedTime;
bool _isHistoryEditMode = false; bool _isHistoryEditMode = false;
bool _rtlTcpEnabled = false;
InputSource _inputSource = InputSource.bluetooth;
bool _rtlTcpConnected = false; bool _rtlTcpConnected = false;
bool _isConnected = false; bool _isConnected = false;
final GlobalKey<HistoryScreenState> _historyScreenKey = final GlobalKey<HistoryScreenState> _historyScreenKey =
@@ -230,7 +254,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_bleService = BLEService(); _bleService = BLEService();
_rtlTcpService = RtlTcpService(); _rtlTcpService = RtlTcpService();
_bleService.initialize(); _bleService.initialize();
_loadRtlTcpSettings(); _loadInputSettings();
_initializeServices(); _initializeServices();
_checkAndStartBackgroundService(); _checkAndStartBackgroundService();
_setupConnectionListener(); _setupConnectionListener();
@@ -248,23 +272,26 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
} }
} }
void _loadRtlTcpSettings() async { void _loadInputSettings() async {
developer.log('rtl_tcp: load_settings');
final settings = await _databaseService.getAllSettings(); final settings = await _databaseService.getAllSettings();
developer.log('rtl_tcp: settings_loaded: enabled=${(settings?['rtlTcpEnabled'] ?? 0) == 1}, host=${settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings?['rtlTcpPort']?.toString() ?? '14423'}'); final sourceStr = settings?['inputSource'] as String? ?? 'bluetooth';
if (mounted) { if (mounted) {
setState(() { setState(() {
_rtlTcpEnabled = (settings?['rtlTcpEnabled'] ?? 0) == 1; _inputSource = InputSource.values.firstWhere(
(e) => e.name == sourceStr,
orElse: () => InputSource.bluetooth,
);
_rtlTcpConnected = _rtlTcpService.isConnected; _rtlTcpConnected = _rtlTcpService.isConnected;
}); });
if (_rtlTcpEnabled && !_rtlTcpConnected) { if (_inputSource == InputSource.rtlTcp && !_rtlTcpConnected) {
final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'; final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1';
final port = settings?['rtlTcpPort']?.toString() ?? '14423'; final port = settings?['rtlTcpPort']?.toString() ?? '14423';
developer.log('rtl_tcp: auto_connect');
_connectToRtlTcp(host, port); _connectToRtlTcp(host, port);
} else { } else if (_inputSource == InputSource.audioInput) {
developer.log('rtl_tcp: skip_connect: enabled=$_rtlTcpEnabled, connected=$_rtlTcpConnected'); await AudioInputService().startListening();
setState(() {});
} }
} }
} }
@@ -292,41 +319,47 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_rtlTcpLastReceivedTimeSubscription = _rtlTcpLastReceivedTimeSubscription =
_rtlTcpService.lastReceivedTimeStream.listen((time) { _rtlTcpService.lastReceivedTimeStream.listen((time) {
if (mounted) { if (mounted) {
if (_rtlTcpEnabled) { setState(() {
setState(() { _rtlTcpLastReceivedTime = time;
_rtlTcpLastReceivedTime = time; });
});
}
} }
}); });
} }
void _setupSettingsListener() { void _setupSettingsListener() {
developer.log('rtl_tcp: setup_listener');
_settingsSubscription = _settingsSubscription =
DatabaseService.instance.onSettingsChanged((settings) { DatabaseService.instance.onSettingsChanged((settings) {
developer.log('rtl_tcp: settings_changed: enabled=${(settings['rtlTcpEnabled'] ?? 0) == 1}, host=${settings['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings['rtlTcpPort']?.toString() ?? '14423'}');
if (mounted) { if (mounted) {
final rtlTcpEnabled = (settings['rtlTcpEnabled'] ?? 0) == 1; final sourceStr = settings['inputSource'] as String? ?? 'bluetooth';
if (rtlTcpEnabled != _rtlTcpEnabled) { print('[MainScreen] Settings changed: inputSource=$sourceStr');
setState(() { final newInputSource = InputSource.values.firstWhere(
_rtlTcpEnabled = rtlTcpEnabled; (e) => e.name == sourceStr,
}); orElse: () => InputSource.bluetooth,
);
if (rtlTcpEnabled) {
final host = settings['rtlTcpHost']?.toString() ?? '127.0.0.1'; print('[MainScreen] Current: $_inputSource, New: $newInputSource');
final port = settings['rtlTcpPort']?.toString() ?? '14423';
_connectToRtlTcp(host, port); setState(() {
} else { _inputSource = newInputSource;
_rtlTcpConnectionSubscription?.cancel(); });
_rtlTcpDataSubscription?.cancel();
_rtlTcpLastReceivedTimeSubscription?.cancel(); switch (newInputSource) {
_rtlTcpService.disconnect(); case InputSource.rtlTcp:
setState(() { setState(() {
_rtlTcpConnected = false; _rtlTcpConnected = _rtlTcpService.isConnected;
_rtlTcpLastReceivedTime = null; });
}); print('[MainScreen] RTL-TCP mode, connected: $_rtlTcpConnected');
} break;
case InputSource.audioInput:
setState(() {});
break;
case InputSource.bluetooth:
_rtlTcpService.disconnect();
setState(() {
_rtlTcpConnected = false;
_rtlTcpLastReceivedTime = null;
});
break;
} }
if (_currentIndex == 1) { if (_currentIndex == 1) {
@@ -347,20 +380,16 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_rtlTcpConnectionSubscription = _rtlTcpService.connectionStream.listen((connected) { _rtlTcpConnectionSubscription = _rtlTcpService.connectionStream.listen((connected) {
if (mounted) { if (mounted) {
if (_rtlTcpEnabled) { setState(() {
setState(() { _rtlTcpConnected = connected;
_rtlTcpConnected = connected; });
});
}
} }
}); });
} }
Future<void> _connectToRtlTcp(String host, String port) async { Future<void> _connectToRtlTcp(String host, String port) async {
developer.log('rtl_tcp: connect: $host:$port');
try { try {
await _rtlTcpService.connect(host: host, port: port); await _rtlTcpService.connect(host: host, port: port);
developer.log('rtl_tcp: connect_req_sent');
} catch (e) { } catch (e) {
developer.log('rtl_tcp: connect_fail: $e'); developer.log('rtl_tcp: connect_fail: $e');
} }
@@ -391,38 +420,37 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
await _notificationService.initialize(); await _notificationService.initialize();
_dataSubscription = _bleService.dataStream.listen((record) { _dataSubscription = _bleService.dataStream.listen((record) {
_notificationService.showTrainNotification(record); if (_inputSource == InputSource.bluetooth) {
if (_historyScreenKey.currentState != null) { _processRecord(record);
_historyScreenKey.currentState!.addNewRecord(record);
}
if (_realtimeScreenKey.currentState != null) {
_realtimeScreenKey.currentState!.addNewRecord(record);
} }
}); });
_rtlTcpDataSubscription = _rtlTcpService.dataStream.listen((record) { _rtlTcpDataSubscription = _rtlTcpService.dataStream.listen((record) {
developer.log('rtl_tcp: recv_data: train=${record.train}'); if (_inputSource != InputSource.bluetooth) {
developer.log('rtl_tcp: recv_json: ${jsonEncode(record.toJson())}'); _processRecord(record);
_notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.addNewRecord(record);
}
if (_realtimeScreenKey.currentState != null) {
_realtimeScreenKey.currentState!.addNewRecord(record);
} }
}); });
} }
void _processRecord(record) {
_notificationService.showTrainNotification(record);
_historyScreenKey.currentState?.addNewRecord(record);
_realtimeScreenKey.currentState?.addNewRecord(record);
}
void _showConnectionDialog() { void _showConnectionDialog() {
_bleService.setAutoConnectBlocked(true); _bleService.setAutoConnectBlocked(true);
showDialog( showDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
builder: (context) => builder: (context) =>
_PixelPerfectBluetoothDialog(bleService: _bleService, rtlTcpEnabled: _rtlTcpEnabled), _PixelPerfectBluetoothDialog(
bleService: _bleService,
inputSource: _inputSource
),
).then((_) { ).then((_) {
_bleService.setAutoConnectBlocked(false); _bleService.setAutoConnectBlocked(false);
if (!_bleService.isManualDisconnect) { if (_inputSource == InputSource.bluetooth && !_bleService.isManualDisconnect) {
_bleService.ensureConnection(); _bleService.ensureConnection();
} }
}); });
@@ -452,6 +480,12 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
); );
} }
final IconData statusIcon = switch (_inputSource) {
InputSource.rtlTcp => Icons.wifi,
InputSource.audioInput => Icons.mic,
InputSource.bluetooth => Icons.bluetooth,
};
return AppBar( return AppBar(
backgroundColor: AppTheme.primaryBlack, backgroundColor: AppTheme.primaryBlack,
elevation: 0, elevation: 0,
@@ -469,12 +503,12 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
rtlTcpService: _rtlTcpService, rtlTcpService: _rtlTcpService,
lastReceivedTime: _lastReceivedTime, lastReceivedTime: _lastReceivedTime,
rtlTcpLastReceivedTime: _rtlTcpLastReceivedTime, rtlTcpLastReceivedTime: _rtlTcpLastReceivedTime,
rtlTcpEnabled: _rtlTcpEnabled, inputSource: _inputSource,
rtlTcpConnected: _rtlTcpConnected, rtlTcpConnected: _rtlTcpConnected,
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
_rtlTcpEnabled ? Icons.wifi : Icons.bluetooth, statusIcon,
color: Colors.white, color: Colors.white,
), ),
onPressed: _showConnectionDialog, onPressed: _showConnectionDialog,
@@ -562,7 +596,6 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
SettingsScreen( SettingsScreen(
onSettingsChanged: () { onSettingsChanged: () {
_loadMapType(); _loadMapType();
_loadRtlTcpSettings();
}, },
), ),
]; ];
@@ -609,8 +642,8 @@ enum _ScanState { initial, scanning, finished }
class _PixelPerfectBluetoothDialog extends StatefulWidget { class _PixelPerfectBluetoothDialog extends StatefulWidget {
final BLEService bleService; final BLEService bleService;
final bool rtlTcpEnabled; final InputSource inputSource;
const _PixelPerfectBluetoothDialog({required this.bleService, required this.rtlTcpEnabled}); const _PixelPerfectBluetoothDialog({required this.bleService, required this.inputSource});
@override @override
State<_PixelPerfectBluetoothDialog> createState() => State<_PixelPerfectBluetoothDialog> createState() =>
_PixelPerfectBluetoothDialogState(); _PixelPerfectBluetoothDialogState();
@@ -625,6 +658,7 @@ class _PixelPerfectBluetoothDialogState
DateTime? _lastReceivedTime; DateTime? _lastReceivedTime;
StreamSubscription? _rtlTcpConnectionSubscription; StreamSubscription? _rtlTcpConnectionSubscription;
bool _rtlTcpConnected = false; bool _rtlTcpConnected = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -640,11 +674,11 @@ class _PixelPerfectBluetoothDialogState
} }
}); });
if (widget.rtlTcpEnabled && widget.bleService.rtlTcpService != null) { if (widget.inputSource == InputSource.rtlTcp && widget.bleService.rtlTcpService != null) {
_rtlTcpConnected = widget.bleService.rtlTcpService!.isConnected; _rtlTcpConnected = widget.bleService.rtlTcpService!.isConnected;
} }
if (!widget.bleService.isConnected && !widget.rtlTcpEnabled) { if (!widget.bleService.isConnected && widget.inputSource == InputSource.bluetooth) {
_startScan(); _startScan();
} }
} }
@@ -684,31 +718,24 @@ class _PixelPerfectBluetoothDialogState
await widget.bleService.disconnect(); await widget.bleService.disconnect();
} }
void _setupLastReceivedTimeListener() {
_lastReceivedTimeSubscription =
widget.bleService.lastReceivedTimeStream.listen((timestamp) {
if (mounted) {
setState(() {
_lastReceivedTime = timestamp;
});
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isConnected = widget.bleService.isConnected; final (String title, Widget content) = switch (widget.inputSource) {
InputSource.rtlTcp => ('RTL-TCP 服务器', _buildRtlTcpView(context)),
InputSource.audioInput => ('音频输入', _buildAudioInputView(context)),
InputSource.bluetooth => (
'蓝牙设备',
widget.bleService.isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context)
),
};
return AlertDialog( return AlertDialog(
title: Text(widget.rtlTcpEnabled ? 'RTL-TCP 服务器' : '蓝牙设备'), title: Text(title),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: SingleChildScrollView( child: SingleChildScrollView(child: content),
child: widget.rtlTcpEnabled
? _buildRtlTcpView(context)
: (isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context)),
),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -733,13 +760,6 @@ class _PixelPerfectBluetoothDialogState
Text(device?.remoteId.str ?? '', Text(device?.remoteId.str ?? '',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center), textAlign: TextAlign.center),
if (_lastReceivedTime != null) ...[
const SizedBox(height: 8),
_LastReceivedTimeWidget(
lastReceivedTime: _lastReceivedTime,
isConnected: widget.bleService.isConnected,
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _disconnect, onPressed: _disconnect,
@@ -785,13 +805,21 @@ class _PixelPerfectBluetoothDialogState
const SizedBox(height: 8), const SizedBox(height: 8),
Text(currentAddress, Text(currentAddress,
style: TextStyle(color: isConnected ? Colors.green : Colors.grey)), style: TextStyle(color: isConnected ? Colors.green : Colors.grey)),
]);
}
Widget _buildAudioInputView(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.mic, size: 48, color: Colors.blue),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_lastReceivedTime != null && isConnected) ...[ Text('监听中',
_LastReceivedTimeWidget( style: Theme.of(context)
lastReceivedTime: _lastReceivedTime, .textTheme
isConnected: isConnected, .titleMedium
), ?.copyWith(fontWeight: FontWeight.bold)),
], const SizedBox(height: 8),
const Text("请使用音频线连接设备",
style: TextStyle(color: Colors.grey)),
]); ]);
} }
@@ -818,4 +846,4 @@ class _PixelPerfectBluetoothDialogState
), ),
); );
} }
} }

View File

@@ -5,6 +5,8 @@ import 'dart:io';
import 'package:lbjconsole/models/merged_record.dart'; import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart'; import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/background_service.dart'; import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/services/audio_input_service.dart';
import 'package:lbjconsole/services/rtl_tcp_service.dart';
import 'package:lbjconsole/themes/app_theme.dart'; import 'package:lbjconsole/themes/app_theme.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -37,7 +39,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
GroupBy _groupBy = GroupBy.trainAndLoco; GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited; TimeWindow _timeWindow = TimeWindow.unlimited;
String _mapType = 'map'; String _mapType = 'map';
bool _rtlTcpEnabled = false;
InputSource _inputSource = InputSource.bluetooth;
String _rtlTcpHost = '127.0.0.1'; String _rtlTcpHost = '127.0.0.1';
String _rtlTcpPort = '14423'; String _rtlTcpPort = '14423';
@@ -52,131 +56,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadRecordCount(); _loadRecordCount();
} }
Widget _buildRtlTcpSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.wifi,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
const Text('RTL-TCP 源', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('启用 RTL-TCP 源', style: AppTheme.bodyLarge),
],
),
Switch(
value: _rtlTcpEnabled,
onChanged: (value) {
setState(() {
_rtlTcpEnabled = value;
});
_saveSettings();
},
activeThumbColor: Theme.of(context).colorScheme.primary,
),
],
),
Visibility(
visible: _rtlTcpEnabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器地址',
hintText: '输入RTL-TCP服务器地址',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpHostController,
onChanged: (value) {
setState(() {
_rtlTcpHost = value;
});
_saveSettings();
},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器端口',
hintText: '输入RTL-TCP服务器端口',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpPortController,
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_rtlTcpPort = value;
});
_saveSettings();
},
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_deviceNameController.dispose();
_rtlTcpHostController.dispose();
_rtlTcpPortController.dispose();
super.dispose();
}
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final settingsMap = await _databaseService.getAllSettings() ?? {}; final settingsMap = await _databaseService.getAllSettings() ?? {};
final settings = MergeSettings.fromMap(settingsMap); final settings = MergeSettings.fromMap(settingsMap);
@@ -193,20 +72,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
_groupBy = settings.groupBy; _groupBy = settings.groupBy;
_timeWindow = settings.timeWindow; _timeWindow = settings.timeWindow;
_mapType = settingsMap['mapType']?.toString() ?? 'webview'; _mapType = settingsMap['mapType']?.toString() ?? 'webview';
_rtlTcpEnabled = (settingsMap['rtlTcpEnabled'] ?? 0) == 1;
_rtlTcpHost = settingsMap['rtlTcpHost']?.toString() ?? '127.0.0.1'; _rtlTcpHost = settingsMap['rtlTcpHost']?.toString() ?? '127.0.0.1';
_rtlTcpPort = settingsMap['rtlTcpPort']?.toString() ?? '14423'; _rtlTcpPort = settingsMap['rtlTcpPort']?.toString() ?? '14423';
_rtlTcpHostController.text = _rtlTcpHost; _rtlTcpHostController.text = _rtlTcpHost;
_rtlTcpPortController.text = _rtlTcpPort; _rtlTcpPortController.text = _rtlTcpPort;
});
}
}
Future<void> _loadRecordCount() async { final sourceStr = settingsMap['inputSource'] as String? ?? 'bluetooth';
final count = await _databaseService.getRecordCount(); _inputSource = InputSource.values.firstWhere(
if (mounted) { (e) => e.name == sourceStr,
setState(() { orElse: () => InputSource.bluetooth,
_recordCount = count; );
}); });
} }
} }
@@ -222,37 +98,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
'groupBy': _groupBy.name, 'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name, 'timeWindow': _timeWindow.name,
'mapType': _mapType, 'mapType': _mapType,
'rtlTcpEnabled': _rtlTcpEnabled ? 1 : 0, 'inputSource': _inputSource.name,
'rtlTcpHost': _rtlTcpHost, 'rtlTcpHost': _rtlTcpHost,
'rtlTcpPort': _rtlTcpPort, 'rtlTcpPort': _rtlTcpPort,
}); });
widget.onSettingsChanged?.call(); widget.onSettingsChanged?.call();
} }
@override Future<void> _switchInputSource(InputSource newSource) async {
Widget build(BuildContext context) { await AudioInputService().stopListening();
return SingleChildScrollView( await RtlTcpService().disconnect();
padding: const EdgeInsets.all(20.0), setState(() {
child: Column( _inputSource = newSource;
crossAxisAlignment: CrossAxisAlignment.start, });
children: [
_buildBluetoothSettings(), switch (newSource) {
const SizedBox(height: 20), case InputSource.audioInput:
_buildAppSettings(), await AudioInputService().startListening();
const SizedBox(height: 20), break;
_buildRtlTcpSettings(), case InputSource.rtlTcp:
const SizedBox(height: 20), RtlTcpService().connect(host: _rtlTcpHost, port: _rtlTcpPort);
_buildMergeSettings(), break;
const SizedBox(height: 20), case InputSource.bluetooth:
_buildDataManagement(), break;
const SizedBox(height: 20), }
_buildAboutSection(),
], _saveSettings();
),
);
} }
Widget _buildBluetoothSettings() { @override
void dispose() {
_deviceNameController.dispose();
_rtlTcpHostController.dispose();
_rtlTcpPortController.dispose();
super.dispose();
}
Widget _buildInputSourceSettings() {
return Card( return Card(
color: AppTheme.tertiaryBlack, color: AppTheme.tertiaryBlack,
elevation: 0, elevation: 0,
@@ -266,48 +148,213 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.bluetooth, Icon(Icons.input, color: Theme.of(context).colorScheme.primary),
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text('蓝牙设备', style: AppTheme.titleMedium), const Text('信号源设置', style: AppTheme.titleMedium),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField(
controller: _deviceNameController,
decoration: InputDecoration( Row(
labelText: '设备名称', mainAxisAlignment: MainAxisAlignment.spaceBetween,
hintText: '输入设备名称', children: [
labelStyle: const TextStyle(color: Colors.white70), const Text('信号源', style: AppTheme.bodyLarge),
hintStyle: const TextStyle(color: Colors.white54), DropdownButton<InputSource>(
border: OutlineInputBorder( value: _inputSource,
borderSide: const BorderSide(color: Colors.white54), items: const [
borderRadius: BorderRadius.circular(12.0), DropdownMenuItem(
value: InputSource.bluetooth,
child: Text('蓝牙设备', style: AppTheme.bodyMedium),
),
DropdownMenuItem(
value: InputSource.rtlTcp,
child: Text('RTL-TCP', style: AppTheme.bodyMedium),
),
DropdownMenuItem(
value: InputSource.audioInput,
child: Text('音频输入', style: AppTheme.bodyMedium),
),
],
onChanged: (value) {
if (value != null) {
_switchInputSource(value);
}
},
dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium,
underline: Container(height: 0),
), ),
enabledBorder: OutlineInputBorder( ],
borderSide: const BorderSide(color: Colors.white54), ),
borderRadius: BorderRadius.circular(12.0),
if (_inputSource == InputSource.bluetooth) ...[
const SizedBox(height: 16),
TextField(
controller: _deviceNameController,
decoration: InputDecoration(
labelText: '蓝牙设备名称',
hintText: '输入设备名称',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
), ),
focusedBorder: OutlineInputBorder( style: const TextStyle(color: Colors.white),
borderSide: onChanged: (value) {
BorderSide(color: Theme.of(context).colorScheme.primary), setState(() {
borderRadius: BorderRadius.circular(12.0), _deviceName = value;
});
_saveSettings();
},
),
],
if (_inputSource == InputSource.rtlTcp) ...[
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器地址',
hintText: '127.0.0.1',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpHostController,
onChanged: (value) {
setState(() {
_rtlTcpHost = value;
});
_saveSettings();
},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器端口',
hintText: '14423',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpPortController,
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_rtlTcpPort = value;
});
_saveSettings();
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
RtlTcpService()
.connect(host: _rtlTcpHost, port: _rtlTcpPort);
},
icon: const Icon(Icons.refresh),
label: const Text("重新连接 RTL-TCP"),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryBlack,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
)
],
if (_inputSource == InputSource.audioInput) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: const Row(
children: [
Icon(Icons.mic, color: Colors.blue),
SizedBox(width: 12),
Expanded(
child: Text(
'音频解调已启用。请通过音频线 (Line-in) 或麦克风输入信号。',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
],
), ),
), ),
style: const TextStyle(color: Colors.white), ],
onChanged: (value) {
setState(() {
_deviceName = value;
});
_saveSettings();
},
),
], ],
), ),
), ),
); );
} }
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputSourceSettings(),
const SizedBox(height: 20),
_buildAppSettings(),
const SizedBox(height: 20),
_buildMergeSettings(),
const SizedBox(height: 20),
_buildDataManagement(),
const SizedBox(height: 20),
_buildAboutSection(),
],
),
);
}
Widget _buildAppSettings() { Widget _buildAppSettings() {
return Card( return Card(
color: AppTheme.tertiaryBlack, color: AppTheme.tertiaryBlack,
@@ -490,86 +537,80 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('分组方式', style: AppTheme.bodyLarge), Row(
const SizedBox(height: 8), mainAxisAlignment: MainAxisAlignment.spaceBetween,
DropdownButtonFormField<GroupBy>( children: [
initialValue: _groupBy, const Text('分组方式', style: AppTheme.bodyLarge),
items: const [ DropdownButton<GroupBy>(
DropdownMenuItem( value: _groupBy,
value: GroupBy.trainOnly, items: const [
child: Text('仅车次号', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: GroupBy.trainOnly,
value: GroupBy.locoOnly, child: Text('仅车次号', style: AppTheme.bodyMedium)),
child: Text('仅机车号', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: GroupBy.locoOnly,
value: GroupBy.trainOrLoco, child: Text('仅机车号', style: AppTheme.bodyMedium)),
child: Text('车次号或机车号', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: GroupBy.trainOrLoco,
value: GroupBy.trainAndLoco, child: Text('车次号或机车号', style: AppTheme.bodyMedium)),
child: Text('车次号与机车号', style: AppTheme.bodyMedium)), DropdownMenuItem(
], value: GroupBy.trainAndLoco,
onChanged: (value) { child: Text('车次号与机车号', style: AppTheme.bodyMedium)),
if (value != null) { ],
setState(() { onChanged: (value) {
_groupBy = value; if (value != null) {
}); setState(() {
_saveSettings(); _groupBy = value;
} });
}, _saveSettings();
decoration: InputDecoration( }
filled: true, },
fillColor: AppTheme.secondaryBlack, dropdownColor: AppTheme.secondaryBlack,
border: OutlineInputBorder( style: AppTheme.bodyMedium,
borderRadius: BorderRadius.circular(12.0), underline: Container(height: 0),
borderSide: BorderSide.none,
), ),
), ],
dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('时间窗口', style: AppTheme.bodyLarge), Row(
const SizedBox(height: 8), mainAxisAlignment: MainAxisAlignment.spaceBetween,
DropdownButtonFormField<TimeWindow>( children: [
initialValue: _timeWindow, const Text('时间窗口', style: AppTheme.bodyLarge),
items: const [ DropdownButton<TimeWindow>(
DropdownMenuItem( value: _timeWindow,
value: TimeWindow.oneHour, items: const [
child: Text('1小时内', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: TimeWindow.oneHour,
value: TimeWindow.twoHours, child: Text('1小时内', style: AppTheme.bodyMedium)),
child: Text('2小时内', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: TimeWindow.twoHours,
value: TimeWindow.sixHours, child: Text('2小时内', style: AppTheme.bodyMedium)),
child: Text('6小时内', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: TimeWindow.sixHours,
value: TimeWindow.twelveHours, child: Text('6小时内', style: AppTheme.bodyMedium)),
child: Text('12小时内', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: TimeWindow.twelveHours,
value: TimeWindow.oneDay, child: Text('12小时内', style: AppTheme.bodyMedium)),
child: Text('24小时内', style: AppTheme.bodyMedium)), DropdownMenuItem(
DropdownMenuItem( value: TimeWindow.oneDay,
value: TimeWindow.unlimited, child: Text('24小时内', style: AppTheme.bodyMedium)),
child: Text('不限时间', style: AppTheme.bodyMedium)), DropdownMenuItem(
], value: TimeWindow.unlimited,
onChanged: (value) { child: Text('不限时间', style: AppTheme.bodyMedium)),
if (value != null) { ],
setState(() { onChanged: (value) {
_timeWindow = value; if (value != null) {
}); setState(() {
_saveSettings(); _timeWindow = value;
} });
}, _saveSettings();
decoration: InputDecoration( }
filled: true, },
fillColor: AppTheme.secondaryBlack, dropdownColor: AppTheme.secondaryBlack,
border: OutlineInputBorder( style: AppTheme.bodyMedium,
borderRadius: BorderRadius.circular(12.0), underline: Container(height: 0),
borderSide: BorderSide.none,
), ),
), ],
dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -712,15 +753,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
String _formatFileSize(int bytes) { Future<void> _loadRecordCount() async {
if (bytes < 1024) return '$bytes B'; final count = await _databaseService.getRecordCount();
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (mounted) {
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; setState(() {
} _recordCount = count;
});
String _formatDateTime(DateTime dateTime) { }
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
} }
Future<String> _getAppVersion() async { Future<String> _getAppVersion() async {
@@ -910,6 +949,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
'mergeRecordsEnabled': 0, 'mergeRecordsEnabled': 0,
'groupBy': 'trainAndLoco', 'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited', 'timeWindow': 'unlimited',
'inputSource': 'bluetooth',
}); });
Navigator.pop(context); Navigator.pop(context);

View File

@@ -0,0 +1,49 @@
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:developer' as developer;
class AudioInputService {
static final AudioInputService _instance = AudioInputService._internal();
factory AudioInputService() => _instance;
AudioInputService._internal();
static const _methodChannel = MethodChannel('org.noxylva.lbjconsole/audio_input');
bool _isListening = false;
bool get isListening => _isListening;
Future<bool> startListening() async {
if (_isListening) return true;
var status = await Permission.microphone.status;
if (!status.isGranted) {
status = await Permission.microphone.request();
if (!status.isGranted) {
developer.log('Microphone permission denied', name: 'AudioInput');
return false;
}
}
try {
await _methodChannel.invokeMethod('start');
_isListening = true;
developer.log('Audio input started', name: 'AudioInput');
return true;
} on PlatformException catch (e) {
developer.log('Failed to start audio input: ${e.message}', name: 'AudioInput');
return false;
}
}
Future<void> stopListening() async {
if (!_isListening) return;
try {
await _methodChannel.invokeMethod('stop');
_isListening = false;
developer.log('Audio input stopped', name: 'AudioInput');
} catch (e) {
developer.log('Error stopping audio input: $e', name: 'AudioInput');
}
}
}

View File

@@ -4,16 +4,23 @@ import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
import 'package:lbjconsole/models/train_record.dart'; import 'package:lbjconsole/models/train_record.dart';
enum InputSource {
bluetooth,
rtlTcp,
audioInput
}
class DatabaseService { class DatabaseService {
static final DatabaseService instance = DatabaseService._internal(); static final DatabaseService instance = DatabaseService._internal();
factory DatabaseService() => instance; factory DatabaseService() => instance;
DatabaseService._internal(); DatabaseService._internal();
static const String _databaseName = 'train_database'; static const String _databaseName = 'train_database';
static const _databaseVersion = 8; static const _databaseVersion = 9;
static const String trainRecordsTable = 'train_records'; static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings'; static const String appSettingsTable = 'app_settings';
@@ -63,6 +70,8 @@ class DatabaseService {
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
developer.log('Database upgrading from $oldVersion to $newVersion', name: 'Database');
if (oldVersion < 2) { if (oldVersion < 2) {
await db.execute( await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0'); 'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0');
@@ -97,6 +106,27 @@ class DatabaseService {
await db.execute( await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpPort TEXT NOT NULL DEFAULT "14423"'); 'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpPort TEXT NOT NULL DEFAULT "14423"');
} }
if (oldVersion < 9) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN inputSource TEXT NOT NULL DEFAULT "bluetooth"');
try {
final List<Map<String, dynamic>> results = await db.query(appSettingsTable, columns: ['rtlTcpEnabled'], where: 'id = 1');
if (results.isNotEmpty) {
final int rtlTcpEnabled = results.first['rtlTcpEnabled'] as int? ?? 0;
if (rtlTcpEnabled == 1) {
await db.update(
appSettingsTable,
{'inputSource': 'rtlTcp'},
where: 'id = 1'
);
developer.log('Migrated V8 settings: inputSource set to rtlTcp', name: 'Database');
}
}
} catch (e) {
developer.log('Migration V8->V9 data update failed: $e', name: 'Database');
}
}
} }
Future<void> _onCreate(Database db, int version) async { Future<void> _onCreate(Database db, int version) async {
@@ -150,7 +180,8 @@ class DatabaseService {
mapSettingsTimestamp INTEGER, mapSettingsTimestamp INTEGER,
rtlTcpEnabled INTEGER NOT NULL DEFAULT 0, rtlTcpEnabled INTEGER NOT NULL DEFAULT 0,
rtlTcpHost TEXT NOT NULL DEFAULT '127.0.0.1', rtlTcpHost TEXT NOT NULL DEFAULT '127.0.0.1',
rtlTcpPort TEXT NOT NULL DEFAULT '14423' rtlTcpPort TEXT NOT NULL DEFAULT '14423',
inputSource TEXT NOT NULL DEFAULT 'bluetooth'
) )
'''); ''');
@@ -177,11 +208,12 @@ class DatabaseService {
'groupBy': 'trainAndLoco', 'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited', 'timeWindow': 'unlimited',
'mapTimeFilter': 'unlimited', 'mapTimeFilter': 'unlimited',
'hideUngroupableRecords': 0, 'hideUngroupableRecords': 0,
'mapSettingsTimestamp': null, 'mapSettingsTimestamp': null,
'rtlTcpEnabled': 0, 'rtlTcpEnabled': 0,
'rtlTcpHost': '127.0.0.1', 'rtlTcpHost': '127.0.0.1',
'rtlTcpPort': '14423', 'rtlTcpPort': '14423',
'inputSource': 'bluetooth',
}); });
} }
@@ -409,14 +441,13 @@ class DatabaseService {
StreamSubscription<void> onSettingsChanged( StreamSubscription<void> onSettingsChanged(
Function(Map<String, dynamic>) listener) { Function(Map<String, dynamic>) listener) {
_settingsListeners.add(listener); _settingsListeners.add(listener);
return Stream.value(null).listen((_) {}) return _SettingsListenerSubscription(() {
..onData((_) {}) _settingsListeners.remove(listener);
..onDone(() { });
_settingsListeners.remove(listener);
});
} }
void _notifySettingsChanged(Map<String, dynamic> settings) { void _notifySettingsChanged(Map<String, dynamic> settings) {
print('[Database] Notifying ${_settingsListeners.length} settings listeners');
for (final listener in _settingsListeners) { for (final listener in _settingsListeners) {
listener(settings); listener(settings);
} }
@@ -499,3 +530,39 @@ class DatabaseService {
} }
} }
} }
class _SettingsListenerSubscription implements StreamSubscription<void> {
final void Function() _onCancel;
bool _isCanceled = false;
_SettingsListenerSubscription(this._onCancel);
@override
Future<void> cancel() async {
if (!_isCanceled) {
_isCanceled = true;
_onCancel();
}
}
@override
void onData(void Function(void data)? handleData) {}
@override
void onDone(void Function()? handleDone) {}
@override
void onError(Function? handleError) {}
@override
void pause([Future<void>? resumeSignal]) {}
@override
void resume() {}
@override
bool get isPaused => false;
@override
Future<E> asFuture<E>([E? futureValue]) => Future.value(futureValue);
}