Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

weicu
liwei1dao 8 months ago
parent
commit
ea2e5b9dfb
  1. 40
      android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt
  2. 87
      lib/core/services/native_localization_sync.dart
  3. 15
      lib/core/translations/language/ar_sa.dart
  4. 15
      lib/core/translations/language/de_de.dart
  5. 16
      lib/core/translations/language/en_us.dart
  6. 16
      lib/core/translations/language/fr_fr.dart
  7. 30
      lib/core/translations/language/ja_jp.dart
  8. 15
      lib/core/translations/language/ko_kr.dart
  9. 15
      lib/core/translations/language/ru_ru.dart
  10. 13
      lib/core/translations/language/zh_cn.dart
  11. 13
      lib/core/translations/language/zh_hk.dart
  12. 13
      lib/core/translations/language/zh_tw.dart
  13. 4
      lib/core/utils/app_lifecycle_manager.dart
  14. 6
      lib/data/services/db/database_helper.dart
  15. 58
      lib/data/services/db/sqflite_api.dart
  16. 4
      lib/main.dart
  17. 505
      lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart
  18. 2
      lib/modules/settings/controllers/settings_controller.dart
  19. 28
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  20. 34
      local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt
  21. 28
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt
  22. 42
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  23. 24
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PlayerService.kt

40
android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt

@ -19,6 +19,7 @@ import android.net.Uri
import android.provider.OpenableColumns
import java.io.File
import java.io.FileOutputStream
import org.json.JSONObject
class MainActivity: FlutterActivity() {
private val TAG = "MainActivity"
@ -34,6 +35,10 @@ class MainActivity: FlutterActivity() {
private val DEEPLINK_CHANNEL = "com.saitong.voitrans/deeplink"
private val DELIVERY_RETRY_DELAY_MS = 300L
private val MAX_DELIVERY_RETRIES = 12
private val NATIVE_LOCALIZATION_CHANNEL = "com.saitong.voitrans/native_localization"
private val NATIVE_LOCALIZATION_PREF_NAME = "native_localization"
private val NATIVE_LOCALIZATION_PREF_KEY_LOCALE = "locale"
private val NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON = "strings_json"
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "🚀 MainActivity onCreate 开始")
@ -148,6 +153,8 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "⚠️ 插件已注册,跳过重复注册")
}
setupNativeLocalizationChannel(flutterEngine)
// Flutter引擎配置完成后,处理待处理的深链接
Handler(Looper.getMainLooper()).postDelayed({
pendingShareIntent?.let { intent ->
@ -172,6 +179,39 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "✅ configureFlutterEngine 完成")
}
private fun setupNativeLocalizationChannel(flutterEngine: FlutterEngine) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NATIVE_LOCALIZATION_CHANNEL)
channel.setMethodCallHandler { call, result ->
when (call.method) {
"updateNativeStrings" -> {
try {
val args = call.arguments as? Map<*, *>
val locale = args?.get("locale") as? String
val strings = args?.get("strings") as? Map<*, *>
val obj = JSONObject()
strings?.forEach { (k, v) ->
val key = k as? String ?: return@forEach
val value = v as? String ?: return@forEach
obj.put(key, value)
}
val prefs = getSharedPreferences(NATIVE_LOCALIZATION_PREF_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putString(NATIVE_LOCALIZATION_PREF_KEY_LOCALE, locale)
.putString(NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON, obj.toString())
.apply()
result.success(null)
} catch (e: Exception) {
result.error("NATIVE_LOCALIZATION_ERROR", e.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun deliverOrQueueIntent(intent: Intent?) {
intent ?: return

87
lib/core/services/native_localization_sync.dart

@ -0,0 +1,87 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../translations/app_translations.dart';
class NativeLocalizationSync {
static const MethodChannel _channel =
MethodChannel('com.saitong.voitrans/native_localization');
static const List<String> nativeKeys = <String>[
'native.notification.appName',
'native.notification.navigation.title',
'native.notification.navigation.keepRunning',
'native.notification.bleAgent.running',
'native.notification.audioRecording.recording',
'native.notification.music.waiting',
'native.notification.music.previous',
'native.notification.music.next',
'native.notification.music.play',
'native.notification.music.pause',
'native.notification.qqMusic.playing',
'unknownSong',
'unknownArtist',
];
static String? _lastLocaleKey;
static int? _lastPayloadHash;
static bool _isSyncing = false;
static Future<void> syncToNative({bool force = false}) async {
if (!Platform.isAndroid) return;
if (_isSyncing) return;
final Locale locale = Get.locale ??
AppTranslations.deviceLocale() ??
AppTranslations.fallbackLocale;
final String localeKey = _toAppTranslationsLocaleKey(locale);
final Map<String, String> payload = _buildPayload(localeKey);
final int payloadHash = Object.hashAll(
payload.entries.map((e) => Object.hash(e.key, e.value)),
);
if (!force &&
_lastLocaleKey == localeKey &&
_lastPayloadHash == payloadHash) {
return;
}
_isSyncing = true;
try {
await _channel.invokeMethod('updateNativeStrings', <String, dynamic>{
'locale': localeKey,
'strings': payload,
});
_lastLocaleKey = localeKey;
_lastPayloadHash = payloadHash;
} finally {
_isSyncing = false;
}
}
static Map<String, String> _buildPayload(String localeKey) {
final Map<String, Map<String, String>> all = AppTranslations().keys;
final Map<String, String>? current = all[localeKey];
final Map<String, String>? fallback =
all['en_US'] ?? all[AppTranslations.fallbackLocale.toString()];
final Map<String, String> result = <String, String>{};
for (final String key in nativeKeys) {
final String? value = current?[key] ?? fallback?[key];
if (value == null || value.isEmpty) continue;
result[key] = value;
}
return result;
}
static String _toAppTranslationsLocaleKey(Locale locale) {
final String language = locale.languageCode;
final String? country = locale.countryCode;
if (country == null || country.isEmpty) {
return language.toLowerCase();
}
return '${language.toLowerCase()}_${country.toUpperCase()}';
}
}

15
lib/core/translations/language/ar_sa.dart

@ -1064,6 +1064,21 @@ const Map<String, String> arSA = {
'storagePermissionPermanentDenial':
'تم اكتشاف أنك رفضت إذن التخزين بشكل دائم. للمتابعة في استخدام هذه الميزة، يرجى الانتقال إلى إعدادات التطبيق لتمكين إذن التخزين يدويًا.',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': 'التنقل جارٍ', // 导航进行中
'native.notification.navigation.keepRunning':
'التنقل جارٍ، يرجى إبقاء التطبيق قيد التشغيل', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running': 'المساعد الصوتي قيد التشغيل', // 语音助手正在运行
'native.notification.audioRecording.recording':
'يتم التسجيل، اضغط للعودة إلى التطبيق', // 正在录音,点击返回应用
'native.notification.music.waiting': 'في انتظار التشغيل', // 等待播放
'native.notification.music.previous': 'السابق', // 上一首
'native.notification.music.next': 'التالي', // 下一首
'native.notification.music.play': 'تشغيل', // 播放
'native.notification.music.pause': 'إيقاف مؤقت', // 暂停
'native.notification.qqMusic.playing': 'يتم تشغيل الموسيقى', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'يحتاج ',
'permissionUtilPermission': ' إذن',

15
lib/core/translations/language/de_de.dart

@ -1101,6 +1101,21 @@ const Map<String, String> deDE = {
'storagePermissionPermanentDenial':
'Speicherberechtigung wurde dauerhaft verweigert. Um diese Funktion weiterhin nutzen zu können, gehen Sie bitte zu den App-Einstellungen, um die Speicherberechtigung manuell zu aktivieren.',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': 'Navigation läuft', // 导航进行中
'native.notification.navigation.keepRunning':
'Navigation läuft, App geöffnet lassen', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running': 'Sprachassistent läuft', // 语音助手正在运行
'native.notification.audioRecording.recording':
'Audioaufnahme läuft, tippen um zur App zurückzukehren', // 正在录音,点击返回应用
'native.notification.music.waiting': 'Warte auf Wiedergabe', // 等待播放
'native.notification.music.previous': 'Vorheriger Titel', // 上一首
'native.notification.music.next': 'Nächster Titel', // 下一首
'native.notification.music.play': 'Wiedergabe', // 播放
'native.notification.music.pause': 'Pause', // 暂停
'native.notification.qqMusic.playing': 'Musik wird wiedergegeben', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'Benötige ',
'permissionUtilPermission': ' Berechtigung',

16
lib/core/translations/language/en_us.dart

@ -1083,6 +1083,22 @@ const Map<String, String> enUS = {
'storagePermissionPermanentDenial':
'Storage permission has been permanently denied. To continue using this feature, please go to app settings to manually enable storage permission.',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': 'Navigation in progress', // 导航进行中
'native.notification.navigation.keepRunning':
'Navigating, keep the app running', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running':
'Voice assistant agent is running', // 语音助手正在运行
'native.notification.audioRecording.recording':
'Recording audio, tap to return to app', // 正在录音,点击返回应用
'native.notification.music.waiting': 'Waiting to play', // 等待播放
'native.notification.music.previous': 'Previous', // 上一首
'native.notification.music.next': 'Next', // 下一首
'native.notification.music.play': 'Play', // 播放
'native.notification.music.pause': 'Pause', // 暂停
'native.notification.qqMusic.playing': 'Playing music', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'Need ',
'permissionUtilPermission': ' Permission',

16
lib/core/translations/language/fr_fr.dart

@ -1088,6 +1088,22 @@ const Map<String, String> frFR = {
'storagePermissionPermanentDenial':
'Storage permission has been permanently denied. To continue using this feature, please go to app settings to manually enable storage permission.',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': 'Navigation en cours', // 导航进行中
'native.notification.navigation.keepRunning':
'Navigation en cours, gardez l’application active', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running':
'Assistant vocal en cours d’exécution', // 语音助手正在运行
'native.notification.audioRecording.recording':
'Enregistrement audio, appuyez pour revenir à l’application', // 正在录音,点击返回应用
'native.notification.music.waiting': 'En attente de lecture', // 等待播放
'native.notification.music.previous': 'Précédent', // 上一首
'native.notification.music.next': 'Suivant', // 下一首
'native.notification.music.play': 'Lecture', // 播放
'native.notification.music.pause': 'Pause', // 暂停
'native.notification.qqMusic.playing': 'Musique en cours de lecture', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'Need ',
'permissionUtilPermission': ' Permission',

30
lib/core/translations/language/ja_jp.dart

@ -439,11 +439,15 @@ const Map<String, String> jaJP = {
'meetingRecordStopFailed': '録音を停止できませんでした。もう一度お試しください。', // 结束录音失败,请重试
'meetingRecordSaveFailed': '録音を保存できませんでした。もう一度お試しください。', // 保存录音失败,请重试
'meetingRecordRecordingNotificationTitle': '録音中', // 录音进行中
'meetingRecordRecordingNotificationBody': 'バックグラウンドで録音しています。タップしてアプリに戻ります。', // 正在后台录音,点击返回应用
'meetingRecordRecordingNotificationBody':
'バックグラウンドで録音しています。タップしてアプリに戻ります。', // 正在后台录音,点击返回应用
'overlayPermissionTitle': '他のアプリの上に表示', // 悬浮窗权限
'overlayPermissionTranslationDesc': '通話中または音声/動画翻訳中に翻訳結果を表示するため、他のアプリの上に表示する権限が必要です。これにより、他のアプリを使用中でもリアルタイム翻訳を確認できます。', // 为了在通话或音视频翻译时显示翻译结果,需要悬浮窗权限。这样您就可以在使用其他应用时看到实时翻译。
'overlayPermissionRecordingDesc': '録音中に録音フローティングバー(タイマー/一時停止/再開)を表示するため、他のアプリの上に表示する権限が必要です。', // 为了在录音过程中显示录音悬浮条(计时/暂停/继续),需要悬浮窗权限。
'overlayPermissionOpenSettingsFailed': 'システム設定を自動で開けませんでした。設定を開き、手動で「他のアプリの上に表示」を有効にしてください。', // 无法自动跳转到系统设置,请手动前往系统设置开启悬浮窗权限。
'overlayPermissionTranslationDesc':
'通話中または音声/動画翻訳中に翻訳結果を表示するため、他のアプリの上に表示する権限が必要です。これにより、他のアプリを使用中でもリアルタイム翻訳を確認できます。', // 为了在通话或音视频翻译时显示翻译结果,需要悬浮窗权限。这样您就可以在使用其他应用时看到实时翻译。
'overlayPermissionRecordingDesc':
'録音中に録音フローティングバー(タイマー/一時停止/再開)を表示するため、他のアプリの上に表示する権限が必要です。', // 为了在录音过程中显示录音悬浮条(计时/暂停/继续),需要悬浮窗权限。
'overlayPermissionOpenSettingsFailed':
'システム設定を自動で開けませんでした。設定を開き、手動で「他のアプリの上に表示」を有効にしてください。', // 无法自动跳转到系统设置,请手动前往系统设置开启悬浮窗权限。
'transcribing': 'Transcribing...',
'summarizing': 'Summarizing...',
'analyzing': 'Analyzing...',
@ -618,8 +622,7 @@ const Map<String, String> jaJP = {
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'この話者のすべての段落に適用',
'summaryWillBeUpdatedAutomatically':
'要約はテンプレートに基づいて自動的に更新されます',
'summaryWillBeUpdatedAutomatically': '要約はテンプレートに基づいて自動的に更新されます',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph',
@ -1032,6 +1035,21 @@ const Map<String, String> jaJP = {
'storagePermissionPermanentDenial':
'Storage permission has been permanently denied. To continue using this feature, please go to app settings to manually enable storage permission.',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': 'ナビゲーション中', // 导航进行中
'native.notification.navigation.keepRunning':
'ナビゲーション中です。アプリを起動したままにしてください', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running': '音声アシスタントが実行中です', // 语音助手正在运行
'native.notification.audioRecording.recording':
'録音中です。タップしてアプリに戻る', // 正在录音,点击返回应用
'native.notification.music.waiting': '再生待機中', // 等待播放
'native.notification.music.previous': '前の曲', // 上一首
'native.notification.music.next': '次の曲', // 下一首
'native.notification.music.play': '再生', // 播放
'native.notification.music.pause': '一時停止', // 暂停
'native.notification.qqMusic.playing': '音楽を再生中', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'Need ',
'permissionUtilPermission': ' Permission',

15
lib/core/translations/language/ko_kr.dart

@ -1027,6 +1027,21 @@ const Map<String, String> koKR = {
'storagePermissionPermanentDenial':
'Storage permission has been permanently denied. To continue using this feature, please go to app settings to manually enable storage permission.',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': '내비게이션 진행 중', // 导航进行中
'native.notification.navigation.keepRunning':
'내비게이션 중입니다. 앱을 실행 상태로 유지하세요', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running': '음성 비서가 실행 중입니다', // 语音助手正在运行
'native.notification.audioRecording.recording':
'녹음 중, 탭하여 앱으로 돌아가기', // 正在录音,点击返回应用
'native.notification.music.waiting': '재생 대기 중', // 等待播放
'native.notification.music.previous': '이전 곡', // 上一首
'native.notification.music.next': '다음 곡', // 下一首
'native.notification.music.play': '재생', // 播放
'native.notification.music.pause': '일시정지', // 暂停
'native.notification.qqMusic.playing': '음악 재생 중', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'Need ',
'permissionUtilPermission': ' Permission',

15
lib/core/translations/language/ru_ru.dart

@ -1093,6 +1093,21 @@ const Map<String, String> ruRU = {
'storagePermissionPermanentDenial':
'检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS', // VOITRANS
'native.notification.navigation.title': 'Навигация запущена', // 导航进行中
'native.notification.navigation.keepRunning':
'Идёт навигация, держите приложение запущенным', // 正在导航中,请保持应用运行
'native.notification.bleAgent.running': 'Голосовой помощник работает', // 语音助手正在运行
'native.notification.audioRecording.recording':
'Идёт запись, нажмите чтобы вернуться в приложение', // 正在录音,点击返回应用
'native.notification.music.waiting': 'Ожидание воспроизведения', // 等待播放
'native.notification.music.previous': 'Предыдущая', // 上一首
'native.notification.music.next': 'Следующая', // 下一首
'native.notification.music.play': 'Воспроизвести', // 播放
'native.notification.music.pause': 'Пауза', // 暂停
'native.notification.qqMusic.playing': 'Воспроизведение музыки', // 正在播放音乐
//permission_util.dart
'permissionUtilNeed': 'Need ',
'permissionUtilPermission': ' Permission',

13
lib/core/translations/language/zh_cn.dart

@ -981,6 +981,19 @@ const Map<String, String> zhCN = {
'storagePermissionPermanentDenial':
'检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS',
'native.notification.navigation.title': '导航进行中',
'native.notification.navigation.keepRunning': '正在导航中,请保持应用运行',
'native.notification.bleAgent.running': '语音助手正在运行',
'native.notification.audioRecording.recording': '正在录音,点击返回应用',
'native.notification.music.waiting': '等待播放',
'native.notification.music.previous': '上一首',
'native.notification.music.next': '下一首',
'native.notification.music.play': '播放',
'native.notification.music.pause': '暂停',
'native.notification.qqMusic.playing': '正在播放音乐',
//permission_util.dart
'permissionUtilNeed': '需要',
'permissionUtilPermission': '权限',

13
lib/core/translations/language/zh_hk.dart

@ -980,6 +980,19 @@ const Map<String, String> zhHK = {
'storagePermissionPermanentDenial':
'檢測到您已永久拒絕儲存權限。如需繼續使用此功能,請前往應用設定中手動開啟儲存權限。',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS',
'native.notification.navigation.title': '導航進行中',
'native.notification.navigation.keepRunning': '正在導航中,請保持應用運行',
'native.notification.bleAgent.running': '語音助手正在運行',
'native.notification.audioRecording.recording': '正在錄音,點擊返回應用',
'native.notification.music.waiting': '等待播放',
'native.notification.music.previous': '上一首',
'native.notification.music.next': '下一首',
'native.notification.music.play': '播放',
'native.notification.music.pause': '暫停',
'native.notification.qqMusic.playing': '正在播放音樂',
//permission_util.dart
'permissionUtilNeed': '需要',
'permissionUtilPermission': '權限',

13
lib/core/translations/language/zh_tw.dart

@ -980,6 +980,19 @@ const Map<String, String> zhTW = {
'storagePermissionPermanentDenial':
'檢測到您已永久拒絕儲存權限。如需繼續使用此功能,請前往應用設定中手動開啟儲存權限。',
// 原生前台服务通知(Android)
'native.notification.appName': 'VOITRANS',
'native.notification.navigation.title': '導航進行中',
'native.notification.navigation.keepRunning': '正在導航中,請保持應用運行',
'native.notification.bleAgent.running': '語音助手正在運行',
'native.notification.audioRecording.recording': '正在錄音,點擊返回應用',
'native.notification.music.waiting': '等待播放',
'native.notification.music.previous': '上一首',
'native.notification.music.next': '下一首',
'native.notification.music.play': '播放',
'native.notification.music.pause': '暫停',
'native.notification.qqMusic.playing': '正在播放音樂',
//permission_util.dart
'permissionUtilNeed': '需要',
'permissionUtilPermission': '權限',

4
lib/core/utils/app_lifecycle_manager.dart

@ -7,6 +7,7 @@ import 'package:get_storage/get_storage.dart';
import '../../modules/meeting/controllers/meeting_record_controller.dart';
import '../../routes/app_routes.dart';
import 'logger.dart';
import '../services/native_localization_sync.dart';
import '../translations/app_translations.dart';
//import '../../modules/settings/controllers/settings_controller.dart';
@ -36,6 +37,7 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
// 注册应用生命周期观察者
WidgetsBinding.instance.addObserver(this);
Logger.info('AppLifecycleManager 已初始化');
unawaited(NativeLocalizationSync.syncToNative(force: true));
unawaited(refreshBleWakeupPolicy());
}
@ -174,6 +176,7 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
// 延迟执行,确保系统语言变化处理完成
Future.delayed(const Duration(milliseconds: 100), () {
Get.updateLocale(userLocale);
unawaited(NativeLocalizationSync.syncToNative(force: true));
Logger.info('已恢复用户设置的语言: ${userLocale.toString()}');
});
} else {
@ -183,6 +186,7 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
Locale? newLocale = AppTranslations.deviceLocale();
if (newLocale != null) {
Get.updateLocale(newLocale);
unawaited(NativeLocalizationSync.syncToNative(force: true));
// 同步更新设置控制器中的语言状态
try {

6
lib/data/services/db/database_helper.dart

@ -94,7 +94,8 @@ class DatabaseHelper {
batch.execute('''
CREATE TABLE IF NOT EXISTS meetingcategorytemplate (
id INTEGER PRIMARY KEY,
name TEXT
name TEXT,
language TEXT
)
''');
// 商务会议助手模版
@ -107,7 +108,8 @@ class DatabaseHelper {
tag TEXT,
desc TEXT,
outline TEXT,
prompt TEXT
prompt TEXT,
language TEXT
)
''');
batch.insert('user', {

58
lib/data/services/db/sqflite_api.dart

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '/core/utils/logger.dart';
import 'database_helper.dart';
@ -182,6 +183,42 @@ class SqfliteApi {
});
}
static String _getMeetingTemplateLanguageCode() {
final locale = Get.locale ?? Get.deviceLocale;
if (locale == null) {
return 'en-US';
}
final code = locale.languageCode.toLowerCase();
switch (code) {
case 'zh':
return 'zh-CN';
case 'en':
return 'en-US';
case 'ja':
return 'ja-JP';
case 'ko':
return 'ko-KR';
case 'fr':
return 'fr-FR';
case 'de':
return 'de-DE';
case 'es':
return 'es-ES';
case 'it':
return 'it-IT';
case 'pt':
return 'pt-BR';
case 'ru':
return 'ru-RU';
case 'ar':
return 'ar-SA';
case 'hi':
return 'hi-IN';
default:
return 'en-US';
}
}
// 获取商务会议助手Ask AI列表
static Future getMeetingAiList(int id) async {
final db = await DatabaseHelper().database;
@ -223,6 +260,7 @@ class SqfliteApi {
'desc': item['description'],
'outline': item['outline'],
'prompt': item['template'],
'language': item['language'],
};
if (result != null) {
result['templates'].add(template);
@ -230,6 +268,7 @@ class SqfliteApi {
categoryList.add({
'name': item['ttype'],
'templates': [template],
'language': item['language'],
});
}
}
@ -252,6 +291,7 @@ class SqfliteApi {
await txn.insert('meetingcategorytemplate', {
'id': categoryId,
'name': item['name'],
'language': item['language'],
});
for (var j = 0; j < item['templates'].length; j++) {
Map template = item['templates'][j];
@ -264,6 +304,7 @@ class SqfliteApi {
'desc': template['desc'],
'outline': template['outline'],
'prompt': template['prompt'],
'language': template['language'],
});
}
}
@ -272,15 +313,22 @@ class SqfliteApi {
// 获取商务会议助手所有分类模版列表
static Future getMeetingCategoryTemplateList() async {
final language = _getMeetingTemplateLanguageCode();
Logger.info('language: $language');
final db = await DatabaseHelper().database;
List categorytemplateList = await db.query('meetingcategorytemplate');
List categorytemplateList = await db.query(
'meetingcategorytemplate',
where: '(language = ? OR language IS NULL OR language = "")',
whereArgs: [language],
);
Logger.info('categorytemplateList: $categorytemplateList');
final dataList =
categorytemplateList.map((item) => Map.from(item)).toList();
for (var element in dataList) {
final List templateList = await db.query(
'meetingtemplate',
limit: 4,
where: 'categoryid = ?',
where: 'categoryid = ? ',
whereArgs: [element['id']],
orderBy: 'id ${element['name'] == '自定义' ? 'DESC' : 'ASC'}',
);
@ -296,11 +344,13 @@ class SqfliteApi {
// 获取商务会议助手模版列表
static Future getMeetingTemplateList(int id, {bool isCustom = false}) async {
final language = _getMeetingTemplateLanguageCode();
Logger.info('language: $language');
final db = await DatabaseHelper().database;
return await db.query(
'meetingtemplate',
where: 'categoryid = ?',
whereArgs: [id],
where: 'categoryid = ? AND language = ?',
whereArgs: [id, language],
orderBy: 'id ${isCustom ? 'DESC' : 'ASC'}',
);
}

4
lib/main.dart

@ -19,6 +19,7 @@ import 'core/themes/app_theme.dart';
import 'core/themes/theme_service.dart';
import 'core/bindings/initial_binding.dart';
import 'core/utils/logger.dart';
import 'core/services/native_localization_sync.dart';
import 'data/services/login_service.dart';
import 'modules/meeting/controllers/meeting_home_controller.dart';
import 'modules/splash/controllers/splash_controller.dart';
@ -165,6 +166,9 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) {
// 在应用构建时立即初始化深链接处理
_initDeepLinkHandler();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(NativeLocalizationSync.syncToNative());
});
// 获取保存的主题设置
final themeMode = ThemeService().getThemeMode();

505
lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart

@ -93,285 +93,288 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
child: Row(
children: [
Icon(
Icons.audio_file_outlined,
color: Colors.grey,
size: 32.w,
),
5.horizontalSpace,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_controller.meetingData.value.title,
style: TextStyle(
fontSize: 16.sp,
color: Colors.black87,
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
child: Row(
children: [
Icon(
Icons.audio_file_outlined,
color: Colors.grey,
size: 32.w,
),
5.horizontalSpace,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_controller.meetingData.value.title,
style: TextStyle(
fontSize: 16.sp,
color: Colors.black87,
),
),
),
Text(
'${_controller.meetingData.value.seconds}s',
style: TextStyle(
fontSize: 12.sp,
color: Colors.grey,
Text(
'${_controller.meetingData.value.seconds}s',
style: TextStyle(
fontSize: 12.sp,
color: Colors.grey,
),
),
),
],
),
),
GestureDetector(
onTap: () => Get.back(),
child: Container(
width: 26.w,
height: 26.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(26.r),
),
child: Icon(
Icons.close,
size: 18.w,
color: Colors.grey[400],
),
),
)
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w),
margin: EdgeInsets.only(bottom: 10.w),
child: Divider(
height: 1,
color: Colors.grey[300],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
child: Row(
children: [
Expanded(
child: Text(
'selectSummaryTemplate'.tr, // 对应中文:选择总结模版
style: TextStyle(
fontSize: 14.sp,
color: Colors.black87,
],
),
),
),
GestureDetector(
onTap: () async {
var result = await Get.to(
() => TemplateView(
_templateData,
_lastTemplateData['id'] ?? 0,
GestureDetector(
onTap: () => Get.back(),
child: Container(
width: 26.w,
height: 26.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(26.r),
),
preventDuplicates: false,
);
if (result != null) {
_scrollController.jumpTo(0);
List list = List.from(_originalTemplate);
int index = list
.indexWhere((element) => element['id'] == result['id']);
if (index != -1) {
Map itemToMove = list.removeAt(index);
list.insert(0, itemToMove);
} else {
list.insert(0, result);
}
setState(() {
_templateData = result;
_templateList = list;
});
}
if (_controller.editTemplate.isNotEmpty) {
Map? editMap = _templateList.firstWhere(
(element) =>
element['id'] == _controller.editTemplate['id'],
orElse: () => null);
if (editMap != null) {
editMap['name'] = _controller.editTemplate['name'];
editMap['desc'] = _controller.editTemplate['desc'];
editMap['prompt'] = _controller.editTemplate['prompt'];
}
if (_controller.editTemplate['id'] == _templateData['id']) {
_templateData['name'] = _controller.editTemplate['name'];
_templateData['desc'] = _controller.editTemplate['desc'];
_templateData['prompt'] =
_controller.editTemplate['prompt'];
}
setState(() {});
}
if (_controller.deleteTemplateId != 0) {
_templateList.removeWhere((element) =>
element['id'] == _controller.deleteTemplateId);
if (_controller.deleteTemplateId == _templateData['id']) {
_templateData = _templateList[0];
}
setState(() {});
_storage.write("meeting_last_template_Data", {});
_storage.write("meeting_template_list", _templateList);
}
},
child: Row(
children: [
Text(
'viewAll'.tr, // 对应中文:查看全部
style: TextStyle(
fontSize: 12.sp,
color: Colors.grey,
),
child: Icon(
Icons.close,
size: 18.w,
color: Colors.grey[400],
),
const Icon(
Icons.keyboard_arrow_right,
color: Colors.grey,
)
],
),
),
],
),
),
Container(
height: 130.w,
margin: EdgeInsets.only(bottom: 16.w),
child: ListView.builder(
controller: _scrollController,
itemCount: _templateList.length,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 4.w),
itemBuilder: (BuildContext context, int index) {
Map data = _templateList[index];
return _item(data);
},
),
)
],
),
),
),
if (!widget.isSummarizeAgain)
Container(
margin: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.r),
padding: EdgeInsets.symmetric(horizontal: 12.w),
margin: EdgeInsets.only(bottom: 10.w),
child: Divider(
height: 1,
color: Colors.grey[300],
),
child: Column(
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
child: Row(
children: [
_row(
'speakerIdentification'.tr, // 对应中文:区分说话人
widget: Transform.scale(
scale: 0.8,
alignment: Alignment.centerRight,
child: Switch(
value: _isSpeaker,
onChanged: (bool value) {
setState(() {
_isSpeaker = value;
});
},
Expanded(
child: Text(
'selectSummaryTemplate'.tr, // 对应中文:选择总结模版
style: TextStyle(
fontSize: 14.sp,
color: Colors.black87,
),
),
),
_row(
'recordingLanguage'.tr, // 对应中文:录音语言
value: _formlanguageText,
onTap: () {
Get.bottomSheet(
RecordLanguageBottomSheet(_formlanguage),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
GestureDetector(
onTap: () async {
var result = await Get.to(
() => TemplateView(
_templateData,
_lastTemplateData['id'] ?? 0,
),
).then((value) {
if (value != null) {
setState(() {
_formlanguageText = value['name'];
_formlanguage = value['value'];
});
preventDuplicates: false,
);
if (result != null) {
_scrollController.jumpTo(0);
List list = List.from(_originalTemplate);
int index = list.indexWhere(
(element) => element['id'] == result['id']);
if (index != -1) {
Map itemToMove = list.removeAt(index);
list.insert(0, itemToMove);
} else {
list.insert(0, result);
}
setState(() {
_templateData = result;
_templateList = list;
});
}
if (_controller.editTemplate.isNotEmpty) {
Map? editMap = _templateList.firstWhere(
(element) =>
element['id'] == _controller.editTemplate['id'],
orElse: () => null);
if (editMap != null) {
editMap['name'] = _controller.editTemplate['name'];
editMap['desc'] = _controller.editTemplate['desc'];
editMap['prompt'] = _controller.editTemplate['prompt'];
}
if (_controller.editTemplate['id'] ==
_templateData['id']) {
_templateData['name'] =
_controller.editTemplate['name'];
_templateData['desc'] =
_controller.editTemplate['desc'];
_templateData['prompt'] =
_controller.editTemplate['prompt'];
}
setState(() {});
}
if (_controller.deleteTemplateId != 0) {
_templateList.removeWhere((element) =>
element['id'] == _controller.deleteTemplateId);
if (_controller.deleteTemplateId == _templateData['id']) {
_templateData = _templateList[0];
}
});
setState(() {});
_storage.write("meeting_last_template_Data", {});
_storage.write("meeting_template_list", _templateList);
}
},
),
_row(
'targetLanguage'.tr, // 对应中文:目标语言
value: _tolanguageText,
isBorder: false,
onTap: () {
Get.bottomSheet(
RecordLanguageBottomSheet(_tolanguage),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
child: Row(
children: [
Text(
'viewAll'.tr, // 对应中文:查看全部
style: TextStyle(
fontSize: 12.sp,
color: Colors.grey,
),
),
).then((value) {
if (value != null) {
setState(() {
_tolanguageText = value['name'];
_tolanguage = value['value'];
});
}
});
},
const Icon(
Icons.keyboard_arrow_right,
color: Colors.grey,
)
],
),
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w),
child: GestureDetector(
onTap: () {
if (User.isLoggedIn() && User.instance.meetintegral <= 0) {
showRechargeDialog('meeting');
return;
}
if (_templateData['id'] != _templateList[0]['id']) {
int index = _templateList
.indexWhere((v) => v['id'] == _templateData['id']);
if (index != -1) {
Map itemToMove = _templateList.removeAt(index);
_templateList.insert(0, itemToMove);
}
}
_storage.write("meeting_last_template_Data", _templateData);
_storage.write("meeting_template_list", _templateList);
Get.back();
if (widget.isSummarizeAgain) {
_controller.refreshSummary(_templateData['id']);
} else {
_controller.summaryCreate(
_formlanguage,
_tolanguage,
_isSpeaker,
_templateData['id'],
);
}
},
child: Container(
height: 48.w,
alignment: Alignment.center,
Container(
height: 130.w,
margin: EdgeInsets.only(bottom: 16.w),
child: ListView.builder(
controller: _scrollController,
itemCount: _templateList.length,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 4.w),
itemBuilder: (BuildContext context, int index) {
Map data = _templateList[index];
return _item(data);
},
),
),
if (!widget.isSummarizeAgain)
Container(
margin: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12.r),
color: Colors.white,
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
'meetingGenerateNow'.tr, // 对应中文:立即生成
style: TextStyle(
fontSize: 16.sp,
color: Colors.white,
child: Column(
children: [
_row(
'speakerIdentification'.tr, // 对应中文:区分说话人
widget: Transform.scale(
scale: 0.8,
alignment: Alignment.centerRight,
child: Switch(
value: _isSpeaker,
onChanged: (bool value) {
setState(() {
_isSpeaker = value;
});
},
),
),
),
_row(
'recordingLanguage'.tr, // 对应中文:录音语言
value: _formlanguageText,
onTap: () {
Get.bottomSheet(
RecordLanguageBottomSheet(_formlanguage),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
).then((value) {
if (value != null) {
setState(() {
_formlanguageText = value['name'];
_formlanguage = value['value'];
});
}
});
},
),
_row(
'targetLanguage'.tr, // 对应中文:目标语言
value: _tolanguageText,
isBorder: false,
onTap: () {
Get.bottomSheet(
RecordLanguageBottomSheet(_tolanguage),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
).then((value) {
if (value != null) {
setState(() {
_tolanguageText = value['name'];
_tolanguage = value['value'];
});
}
});
},
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w),
child: GestureDetector(
onTap: () {
if (User.isLoggedIn() && User.instance.meetintegral <= 0) {
showRechargeDialog('meeting');
return;
}
if (_templateData['id'] != _templateList[0]['id']) {
int index = _templateList
.indexWhere((v) => v['id'] == _templateData['id']);
if (index != -1) {
Map itemToMove = _templateList.removeAt(index);
_templateList.insert(0, itemToMove);
}
}
_storage.write("meeting_last_template_Data", _templateData);
_storage.write("meeting_template_list", _templateList);
Get.back();
if (widget.isSummarizeAgain) {
_controller.refreshSummary(_templateData['id']);
} else {
_controller.summaryCreate(
_formlanguage,
_tolanguage,
_isSpeaker,
_templateData['id'],
);
}
},
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'meetingGenerateNow'.tr, // 对应中文:立即生成
style: TextStyle(
fontSize: 16.sp,
color: Colors.white,
),
),
),
),
),
),
],
),
);

2
lib/modules/settings/controllers/settings_controller.dart

@ -8,6 +8,7 @@ import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/themes/theme_service.dart';
import '../../../core/services/native_localization_sync.dart';
import '../../../core/translations/app_translations.dart';
import '../../../data/services/network/dio_manager.dart';
import '../../../routes/app_routes.dart';
@ -128,6 +129,7 @@ class SettingsController extends GetxController {
Get.updateLocale(targetLocale);
Logger().i("使用fallback语言: ${targetLocale.toString()}");
}
await NativeLocalizationSync.syncToNative(force: true);
// 保存用户选择
currentLanguage.value = selectedLanguage;

28
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt

@ -12,6 +12,7 @@ import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import android.util.Log
import org.json.JSONObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@ -47,6 +48,8 @@ class BleMediaService : Service(), CoroutineScope {
private const val CHANNEL_ID = "ble_agent_service_channel"
private const val NOTIFICATION_ID = 1002
private const val PREF_NAME = "deep_voice_config"
private const val NATIVE_LOCALIZATION_PREF_NAME = "native_localization"
private const val NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON = "strings_json"
// 目标蓝牙设备名称
private const val TARGET_DEVICE_NAME = "DeapSound"
@ -412,6 +415,10 @@ private var companionManager: BleCompanionManager? = null
// 设置服务状态
isRunning.set(true)
// 前台通知显示条件:
// - 只要 BleMediaService 被启动(Flutter 调用 AgentService.startAgentService),onCreate 会立刻执行 startForeground();
// - 渠道使用 IMPORTANCE_LOW,通常不会弹出“抬头横幅(Heads-up)”,但会在下拉通知栏里显示常驻通知;
// - Flutter 侧常见入口:PairingController 跳过配对时会调用 BleManager.startAgentService()。
startForeground(NOTIFICATION_ID, createNotification())
// 初始化BLE伴侣设备管理器 - 使用单例模式获取实例
companionManager = BleCompanionManager.getInstance(this)
@ -882,11 +889,16 @@ private var companionManager: BleCompanionManager? = null
}
val appIconResId = applicationInfo.icon.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
val title = getNativeLocalizedString("native.notification.appName", "Voitrans")
val content = getNativeLocalizedString(
"native.notification.bleAgent.running",
"Voice assistant agent is running"
)
// 构建通知
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Voitrans")
.setContentText("Voice assistant agent is running")
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(appIconResId)
.setOngoing(true)
.setOnlyAlertOnce(true)
@ -899,4 +911,16 @@ private var companionManager: BleCompanionManager? = null
return builder.build()
}
private fun getNativeLocalizedString(key: String, defaultValue: String): String {
return try {
val prefs = getSharedPreferences(NATIVE_LOCALIZATION_PREF_NAME, Context.MODE_PRIVATE)
val json = prefs.getString(NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON, null) ?: return defaultValue
val obj = JSONObject(json)
val value = obj.optString(key, defaultValue)
if (value.isBlank()) defaultValue else value
} catch (_: Exception) {
defaultValue
}
}
}

34
local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt

@ -14,6 +14,7 @@ import android.util.Base64
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.json.JSONObject
import com.amap.api.navi.AMapNavi
import com.amap.api.navi.AMapNaviListener
import com.amap.api.navi.enums.PathPlanningStrategy
@ -42,6 +43,8 @@ class NavigationService: Service(),AMapNaviListener {
private const val CHANNEL_ID = "navigation_channel"
private const val CHANNEL_Name = "导航服务"
private const val NOTIFICATION_ID = 1003
private const val NATIVE_LOCALIZATION_PREF_NAME = "native_localization"
private const val NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON = "strings_json"
private val isRunning = AtomicBoolean(false)
private var startpos: String = ""
@ -58,6 +61,11 @@ class NavigationService: Service(),AMapNaviListener {
override fun onCreate() {
super.onCreate()
instance = this
// 前台通知显示条件:
// - 只要该 Service 被启动(ContextCompat.startForegroundService / startForegroundService),onCreate 就会立刻调用 startForeground();
// - 通知渠道使用 IMPORTANCE_LOW,通常不会弹出“抬头横幅(Heads-up)”,但会在下拉通知栏里出现常驻通知;
// - Flutter 侧需要调用插件 com.yunqiinnovation.amap_service 的 "start"(会走 NavigationServiceHelper.startNavigation -> 启动该 Service),
// 调用 "open" 只会打开导航页面,不会启动该前台服务,因此不会有通知。
startForegroundNotification()
// 注册广播监听器
@ -135,19 +143,27 @@ class NavigationService: Service(),AMapNaviListener {
manager.createNotificationChannel(channel)
}
val title = getNativeLocalizedString("native.notification.navigation.title", "导航进行中")
val content = getNativeLocalizedString(
"native.notification.navigation.keepRunning",
"正在导航中,请保持应用运行"
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("导航进行中")
.setContentText("正在导航中,请保持应用运行")
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(R.drawable.ic_navigation) // 使用你自己的图标资源
.setOngoing(true)
.build()
// 触发系统显示“前台服务常驻通知”的关键点:startForeground()
// 如果 Android 13+ 未授予通知权限 / 用户关闭了该渠道通知,可能导致通知不可见或启动前台服务失败(不同 ROM 行为可能不同)。
startForeground(NOTIFICATION_ID, notification)
}
private fun updateNotification(contentText: String, iconBitmap: Bitmap?) {
val title = getNativeLocalizedString("native.notification.navigation.title", "导航进行中")
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("导航进行中")
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(R.drawable.ic_navigation) // 通知栏图标(小)
.setOnlyAlertOnce(true)
@ -162,6 +178,18 @@ class NavigationService: Service(),AMapNaviListener {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun getNativeLocalizedString(key: String, defaultValue: String): String {
return try {
val prefs = getSharedPreferences(NATIVE_LOCALIZATION_PREF_NAME, Context.MODE_PRIVATE)
val json = prefs.getString(NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON, null) ?: return defaultValue
val obj = JSONObject(json)
val value = obj.optString(key, defaultValue)
if (value.isBlank()) defaultValue else value
} catch (_: Exception) {
defaultValue
}
}
private fun tintBitmap(source: Bitmap, tintColor: Int): Bitmap {
val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(result)

28
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt

@ -11,6 +11,7 @@ import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import android.util.Log
import org.json.JSONObject
/**
* 音频录制前台服务
@ -24,6 +25,8 @@ class AudioRecordingForegroundService : Service() {
private const val CHANNEL_ID = "audio_recording_channel"
private const val CHANNEL_NAME = "音频录制服务"
private const val ACTION_STOP = "com.yunqiinnovation.azure_speech.tools.STOP_SERVICE"
private const val NATIVE_LOCALIZATION_PREF_NAME = "native_localization"
private const val NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON = "strings_json"
/**
* 启动前台服务
@ -62,6 +65,10 @@ class AudioRecordingForegroundService : Service() {
// 创建前台通知
val notification = createNotification()
// 前台通知显示条件:
// - 只要该 Service 被启动(AzureSpeechPlugin/AzureAsrHelper 在 enableRecord 时会启动它),就会在 onStartCommand 里立刻调用 startForeground();
// - 渠道为 IMPORTANCE_LOW,通常不会弹出“抬头横幅(Heads-up)”,但会在下拉通知栏显示常驻通知;
// - Android 13+ 如果未授予通知权限 / 用户关闭通知,可能出现通知不可见或启动前台服务失败(不同 ROM 行为可能不同)。
// 必须立即调用 startForeground 以满足 Android 8.0+ 的要求
startForeground(NOTIFICATION_ID, notification)
@ -124,9 +131,14 @@ class AudioRecordingForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val title = getNativeLocalizedString("native.notification.appName", "Voitrans")
val content = getNativeLocalizedString(
"native.notification.audioRecording.recording",
"Recording audio, tap to return to app"
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Voitrans")
.setContentText("Recording audio, tap to return to app")
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(applicationInfo.icon)
.setContentIntent(pendingIntent)
.setOngoing(true) // 设置为持续通知,用户无法滑动删除
@ -136,4 +148,16 @@ class AudioRecordingForegroundService : Service() {
.setAutoCancel(false) // 点击通知后不自动取消
.build()
}
private fun getNativeLocalizedString(key: String, defaultValue: String): String {
return try {
val prefs = getSharedPreferences(NATIVE_LOCALIZATION_PREF_NAME, Context.MODE_PRIVATE)
val json = prefs.getString(NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON, null) ?: return defaultValue
val obj = JSONObject(json)
val value = obj.optString(key, defaultValue)
if (value.isBlank()) defaultValue else value
} catch (_: Exception) {
defaultValue
}
}
}

42
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -25,6 +25,7 @@ import androidx.media.MediaBrowserServiceCompat
import androidx.media.session.MediaButtonReceiver
import android.util.Log
import android.view.KeyEvent
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicBoolean
@ -36,6 +37,8 @@ class MusicService : MediaBrowserServiceCompat() {
private const val CHANNEL_ID = "music_media_service_channel"
private const val NOTIFICATION_ID = 1002
private const val DELETE_ACTION = "com.yunqiinnovation.music_service.DELETE_NOTIFICATION"
private const val NATIVE_LOCALIZATION_PREF_NAME = "native_localization"
private const val NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON = "strings_json"
private val isRunning = AtomicBoolean(false)
private var currentIdInternal: Long? = null
@ -87,8 +90,10 @@ class MusicService : MediaBrowserServiceCompat() {
fun updateMediaSessionMetadata(song: Map<String, Any>) {
instance?.let { service ->
try {
val title = song["title"] as? String ??: "未知标题"
val artist = song["artist"] as? String ??: "未知歌手"
val title = song["title"] as? String
?: service.getNativeLocalizedString("unknownSong", "未知歌曲")
val artist = song["artist"] as? String
?: service.getNativeLocalizedString("unknownArtist", "未知歌手")
val coverUrl = song["coverUrl"] as? String ??: ""
currentIdInternal = (song["id"] as? Number)?.toLong() ?: 0L
Log.i(TAG, "updateMediaSessionMetadata currentIdInternal: $currentIdInternal")
@ -147,7 +152,14 @@ class MusicService : MediaBrowserServiceCompat() {
instance = this
isRunning.set(true)
setupMediaSession()
startForeground(NOTIFICATION_ID, createNotification("等待播放","Voitrans",""))
val waitingTitle = getNativeLocalizedString("native.notification.music.waiting", "等待播放")
val appName = getNativeLocalizedString("native.notification.appName", "Voitrans")
// 前台通知显示条件:
// - 只要 MusicService 被启动(Flutter 调用 com.yunqiinnovation.music_service 的 play/playlist 等会启动该服务),
// onCreate 会立刻调用 startForeground(),因此下拉通知栏会出现常驻通知(哪怕还没真正开始播放);
// - 渠道为 IMPORTANCE_LOW,通常不会弹出“抬头横幅(Heads-up)”,但会在通知栏里常驻;
// - stop/close 会在 completeStop() 里 stopForeground(true)+stopSelf(),通知随之消失。
startForeground(NOTIFICATION_ID, createNotification(waitingTitle, appName, ""))
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
// 注册广播监听器
@ -386,6 +398,12 @@ private fun updateProgress() {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val prevLabel = getNativeLocalizedString("native.notification.music.previous", "上一首")
val nextLabel = getNativeLocalizedString("native.notification.music.next", "下一首")
val playLabel = getNativeLocalizedString("native.notification.music.play", "播放")
val pauseLabel = getNativeLocalizedString("native.notification.music.pause", "暂停")
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(artist)
@ -396,13 +414,13 @@ private fun updateProgress() {
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 2)
)
.addAction(R.drawable.ic_prev, "上一首", prevIntent)
.addAction(R.drawable.ic_prev, prevLabel, prevIntent)
.addAction(
if (currentStatus) R.drawable.ic_pause else R.drawable.ic_play,
if (currentStatus) "暂停" else "播放",
if (currentStatus) pauseLabel else playLabel,
playPauseIntent
)
.addAction(R.drawable.ic_next, "下一首", nextIntent)
.addAction(R.drawable.ic_next, nextLabel, nextIntent)
.setOngoing(false)
.setDeleteIntent(deletePendingIntent) // 设置通知被滑动删除时的行为
// 异步加载封面图
@ -430,6 +448,18 @@ private fun updateProgress() {
return builder.build()
}
private fun getNativeLocalizedString(key: String, defaultValue: String): String {
return try {
val prefs = getSharedPreferences(NATIVE_LOCALIZATION_PREF_NAME, Context.MODE_PRIVATE)
val json = prefs.getString(NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON, null) ?: return defaultValue
val obj = JSONObject(json)
val value = obj.optString(key, defaultValue)
if (value.isBlank()) defaultValue else value
} catch (_: Exception) {
defaultValue
}
}
fun mediaButtonPendingIntent(action: Long): PendingIntent {
val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, action.toKeyCode())
val intent = Intent(Intent.ACTION_MEDIA_BUTTON).apply {

24
local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PlayerService.kt

@ -17,6 +17,7 @@ import com.yunqiinnovation.qq_music.R
import com.yunqiinnovation.qq_music.openid.OpenIDHelper
import com.yunqiinnovation.qq_music.util.QPlayBindHelper
import com.tencent.qqmusic.third.api.contract.*
import org.json.JSONObject
/**
* Created by clydeazhang on 2021/5/11 6:43 PM.
@ -28,6 +29,8 @@ class PlayerService : Service() {
private const val TAG = "PlayerService"
private const val KEY_ENTER_FOREGROUND = "KEY_ENTER_FOREGROUND"
private const val CHANNEL_ID = "default"
private const val NATIVE_LOCALIZATION_PREF_NAME = "native_localization"
private const val NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON = "strings_json"
}
private val qPlayBindHelper = QPlayBindHelper(this)
@ -103,16 +106,33 @@ class PlayerService : Service() {
val enterForeground = intent?.getBooleanExtra(KEY_ENTER_FOREGROUND, false) ?: false
Log.d(TAG, "onStartCommand, enter foreground=$enterForeground")
if (enterForeground) {
val title = getNativeLocalizedString("native.notification.appName", "Voitrans")
val content = getNativeLocalizedString(
"native.notification.qqMusic.playing",
"Playing music"
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentTitle("demo标题")
.setContentText("demo正在播放音乐")
.setContentTitle(title)
.setContentText(content)
.build()
startForeground(1, notification)
}
return super.onStartCommand(intent, flags, startId)
}
private fun getNativeLocalizedString(key: String, defaultValue: String): String {
return try {
val prefs = getSharedPreferences(NATIVE_LOCALIZATION_PREF_NAME, Context.MODE_PRIVATE)
val json = prefs.getString(NATIVE_LOCALIZATION_PREF_KEY_STRINGS_JSON, null) ?: return defaultValue
val obj = JSONObject(json)
val value = obj.optString(key, defaultValue)
if (value.isBlank()) defaultValue else value
} catch (_: Exception) {
defaultValue
}
}
override fun onDestroy() {
super.onDestroy()
try {

Loading…
Cancel
Save