diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index de01cd468..d26cbc350 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,6 +5,7 @@ repositories { mavenCentral() flatDir { dirs(project(":spotify_sdk").projectDir.resolve("libs")) + dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行 } } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 4199b3b58..af6904e7f 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -41,9 +41,10 @@ include(":agent_service") include(":speech") include(":chat_api") include(":bytedance_speech") - +include(":qq_music") // 添加这行 include(":jl_opus") -// 设置azure_speech项目的路径 + +// 设置项目路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") project(":jieli").projectDir = file("../local_plugins/jieli/android") @@ -54,4 +55,5 @@ project(":agent_service").projectDir = file("../local_plugins/agent_service/andr project(":speech").projectDir = file("../local_plugins/speech/android") project(":chat_api").projectDir = file("../local_plugins/chat_api/android") project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android") +project(":qq_music").projectDir = file("../local_plugins/qq_music/android") // 添加这行 project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android") diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 73826786b..8c98da39d 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -1,3 +1,5 @@ +import 'package:voitrans/data/services/qq_musoc.dart'; + import '../../../data/services/user_portrait.dart'; import '../../../data/services/location_manager.dart'; @@ -63,6 +65,8 @@ class InitialBinding extends Bindings { // 会议助手音频上传 Get.lazyPut(() => UserportraitManager(), fenix: true); + // qq音乐服务 + Get.lazyPut(() => QqMusocService(), fenix: true); // 杰理蓝牙服务 diff --git a/lib/data/services/qq_musoc.dart b/lib/data/services/qq_musoc.dart new file mode 100644 index 000000000..d26a90241 --- /dev/null +++ b/lib/data/services/qq_musoc.dart @@ -0,0 +1,157 @@ +import 'package:get/get.dart'; +import 'package:qq_music/qq_music.dart'; + +/// QQ音乐服务适配器 +class QqMusocService extends GetxService { + static QqMusocService get to => Get.find(); + + var isInitialized = false; + // 音乐服务实例 + final QqMusic _qqMusic = QqMusic(); + + /// 初始化 + @override + void onInit() { + super.onInit(); + } + + /// 登录QQ音乐 + Future> login() async { + try { + // 直接调用QQ音乐插件的登录方法 + final result = await _qqMusic.login(); + + // 检查登录结果 + if (result['success'] == true) { + isInitialized = true; + return result; + } else { + return {'success': false, 'error': result['error'] ?? '登录失败'}; + } + } catch (e) { + return {'success': false, 'error': '登录异常: ${e.toString()}'}; + } + } + + /// 搜索歌曲 + Future> searchSongs({ + required String keyword, + int searchType = 0, // SEARCH_TYPE_SONG + }) async { + try { + final result = await _qqMusic.searchSongs( + keyword: keyword, + searchType: searchType, + ); + return result; + } catch (e) { + return {'success': false, 'error': '搜索失败: ${e.toString()}'}; + } + } + + /// 播放歌曲列表 + Future> playSongs({ + required List songMids, + int index = 0, + }) async { + try { + final result = await _qqMusic.playSongs( + songMids: songMids, + index: index, + ); + return result; + } catch (e) { + return {'success': false, 'error': '播放失败: ${e.toString()}'}; + } + } + + /// 播放音乐 + Future> playMusic() async { + try { + final result = await _qqMusic.playMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '播放失败: ${e.toString()}'}; + } + } + + /// 暂停音乐 + Future> pauseMusic() async { + try { + final result = await _qqMusic.pauseMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '暂停失败: ${e.toString()}'}; + } + } + + /// 继续播放 + Future> resumeMusic() async { + try { + final result = await _qqMusic.resumeMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '继续播放失败: ${e.toString()}'}; + } + } + + /// 停止音乐 + Future> stopMusic() async { + try { + final result = await _qqMusic.stopMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '停止失败: ${e.toString()}'}; + } + } + + /// 下一首 + Future> skipToNext() async { + try { + final result = await _qqMusic.skipToNext(); + return result; + } catch (e) { + return {'success': false, 'error': '下一首失败: ${e.toString()}'}; + } + } + + /// 上一首 + Future> skipToPrevious() async { + try { + final result = await _qqMusic.skipToPrevious(); + return result; + } catch (e) { + return {'success': false, 'error': '上一首失败: ${e.toString()}'}; + } + } + + /// 获取播放状态 + Future> getPlaybackState() async { + try { + final result = await _qqMusic.getPlaybackState(); + return result; + } catch (e) { + return {'success': false, 'error': '获取播放状态失败: ${e.toString()}'}; + } + } + + /// 获取当前播放歌曲 + Future> getCurrentSong() async { + try { + final result = await _qqMusic.getCurrentSong(); + return result; + } catch (e) { + return {'success': false, 'error': '获取当前歌曲失败: ${e.toString()}'}; + } + } + + /// 获取播放时间 + Future> getPlayTime() async { + try { + final result = await _qqMusic.getPlayTime(); + return result; + } catch (e) { + return {'success': false, 'error': '获取播放时间失败: ${e.toString()}'}; + } + } +} diff --git a/lib/modules/qqmusic/bindings/qqmusic_binding.dart b/lib/modules/qqmusic/bindings/qqmusic_binding.dart new file mode 100644 index 000000000..1050ea386 --- /dev/null +++ b/lib/modules/qqmusic/bindings/qqmusic_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/qqmusic_controller.dart'; + +class QQMusicBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QQMusicController()); + } +} \ No newline at end of file diff --git a/lib/modules/qqmusic/controllers/qqmusic_controller.dart b/lib/modules/qqmusic/controllers/qqmusic_controller.dart new file mode 100644 index 000000000..a828389cc --- /dev/null +++ b/lib/modules/qqmusic/controllers/qqmusic_controller.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:qq_music/qq_music.dart'; +import 'package:voitrans/data/services/qq_musoc.dart'; +import '../models/song_model.dart'; + +class QQMusicController extends GetxController { + final QqMusocService _qqMusic = Get.find(); + + // 登录状态 + final isLoggedIn = false.obs; + final userInfo = Rxn(); + + // 搜索相关 + final searchResults = [].obs; + final isSearching = false.obs; + final searchKeyword = ''.obs; + + // 播放相关 + final currentSong = Rxn(); + final isPlaying = false.obs; + final currentPosition = 0.obs; + final totalDuration = 0.obs; + final playlist = [].obs; + final currentIndex = 0.obs; + + @override + void onInit() { + super.onInit(); + _checkLoginStatus(); + _startPlaybackStateMonitor(); + } + + /// 检查登录状态 + void _checkLoginStatus() { + // 这里可以检查本地存储的登录信息 + // 暂时设为未登录状态 + isLoggedIn.value = false; + } + + /// QQ音乐登录 + Future login() async { + try { + print('开始QQ音乐登录...'); + + Get.dialog( + const Center( + child: CircularProgressIndicator(), + ), + barrierDismissible: false, + ); + + // 添加超时处理 + final result = await _qqMusic.login().timeout( + const Duration(seconds: 30), + onTimeout: () { + print('QQ音乐登录超时'); + return {'success': false, 'error': '登录超时,请重试'}; + }, + ); + + print('QQ音乐登录结果: $result'); + + Get.back(); // 关闭加载对话框 + + if (result['success'] == true) { + userInfo.value = QQMusicLoginResult.fromMap(result); + isLoggedIn.value = true; + print('QQ音乐登录成功: ${result['openId']}'); + Get.snackbar('登录成功', '欢迎使用QQ音乐'); + } else { + print('QQ音乐登录失败: ${result['error']}'); + Get.snackbar('登录失败', result['error'] ?? '未知错误'); + } + } catch (e) { + print('QQ音乐登录异常: $e'); + Get.back(); + Get.snackbar('登录失败', e.toString()); + } + } + + /// 退出登录 + void logout() { + userInfo.value = null; + isLoggedIn.value = false; + currentSong.value = null; + isPlaying.value = false; + playlist.clear(); + Get.snackbar('已退出', '已退出QQ音乐登录'); + } + + /// 搜索歌曲 + Future searchSongs(String keyword) async { + if (keyword.trim().isEmpty) return; + + print('开始搜索歌曲: $keyword'); + searchKeyword.value = keyword; + isSearching.value = true; + + try { + // 检查登录状态 + if (!isLoggedIn.value) { + print('用户未登录,无法搜索'); + Get.snackbar('搜索失败', '请先登录QQ音乐'); + return; + } + + print('调用搜索接口...'); + final result = await _qqMusic.searchSongs(keyword: keyword).timeout( + const Duration(seconds: 15), + onTimeout: () { + print('搜索超时'); + return {'success': false, 'error': '搜索超时,请重试'}; + }, + ); + + print('搜索结果: $result'); + + if (result['success'] == true && result['songs'] != null) { + final List songsData = result['songs']; + print('找到 ${songsData.length} 首歌曲'); + + final songs = songsData.map((songData) { + return SongModel( + mid: songData['mid'] ?? '', + name: songData['name'] ?? '', + artist: songData['singer'] ?? '', + album: songData['album'] ?? '', + albumPic: songData['pic'], + duration: songData['duration'] ?? 0, + ); + }).toList(); + + searchResults.assignAll(songs); + print('搜索成功,更新UI'); + } else { + print('搜索失败: ${result['error']}'); + Get.snackbar('搜索失败', result['error'] ?? '搜索结果为空'); + searchResults.clear(); + } + } catch (e) { + print('搜索异常: $e'); + Get.snackbar('搜索失败', e.toString()); + searchResults.clear(); + } finally { + print('搜索完成,重置状态'); + isSearching.value = false; + } + } + + /// 播放歌曲 + Future playSong(SongModel song, {List? songList}) async { + try { + currentSong.value = song; + + if (songList != null) { + playlist.assignAll(songList); + currentIndex.value = songList.indexOf(song); + } else { + playlist.assignAll([song]); + currentIndex.value = 0; + } + + // 获取歌曲MID列表 + final songMids = playlist.map((s) => s.mid).toList(); + final playIndex = currentIndex.value; + + // 调用QQ音乐播放接口 + final result = await _qqMusic.playSongs( + songMids: songMids, + index: playIndex, + ); + + if (result['success'] == true) { + isPlaying.value = true; + totalDuration.value = song.duration; + currentPosition.value = 0; + Get.snackbar('开始播放', song.name); + } else { + Get.snackbar('播放失败', result['error'] ?? '未知错误'); + } + } catch (e) { + Get.snackbar('播放失败', e.toString()); + } + } + + /// 暂停/继续播放 + Future togglePlayPause() async { + try { + if (isPlaying.value) { + final result = await _qqMusic.pauseMusic(); + if (result['success'] == true) { + isPlaying.value = false; + } + } else { + final result = await _qqMusic.resumeMusic(); + if (result['success'] == true) { + isPlaying.value = true; + } + } + } catch (e) { + Get.snackbar('操作失败', e.toString()); + } + } + + /// 上一首 + Future previousSong() async { + try { + final result = await _qqMusic.skipToPrevious(); + if (result['success'] == true) { + if (playlist.isNotEmpty) { + int newIndex = currentIndex.value - 1; + if (newIndex < 0) newIndex = playlist.length - 1; + currentIndex.value = newIndex; + currentSong.value = playlist[newIndex]; + } + await _updateCurrentSong(); + } + } catch (e) { + Get.snackbar('切换失败', e.toString()); + } + } + + /// 下一首 + Future nextSong() async { + try { + final result = await _qqMusic.skipToNext(); + if (result['success'] == true) { + if (playlist.isNotEmpty) { + int newIndex = currentIndex.value + 1; + if (newIndex >= playlist.length) newIndex = 0; + currentIndex.value = newIndex; + currentSong.value = playlist[newIndex]; + } + await _updateCurrentSong(); + } + } catch (e) { + Get.snackbar('切换失败', e.toString()); + } + } + + /// 停止播放 + Future stopMusic() async { + try { + final result = await _qqMusic.stopMusic(); + if (result['success'] == true) { + isPlaying.value = false; + currentPosition.value = 0; + } + } catch (e) { + Get.snackbar('停止失败', e.toString()); + } + } + + /// 更新当前播放歌曲信息 + Future _updateCurrentSong() async { + try { + final result = await _qqMusic.getCurrentSong(); + if (result['success'] == true && result['song'] != null) { + final songData = result['song']; + final song = SongModel( + mid: songData['mid'] ?? '', + name: songData['name'] ?? '', + artist: songData['singer'] ?? '', + album: songData['album'] ?? '', + albumPic: songData['pic'], + duration: songData['duration'] ?? 0, + ); + currentSong.value = song; + } + } catch (e) { + print('更新当前歌曲信息失败: $e'); + } + } + + /// 启动播放状态监控 + void _startPlaybackStateMonitor() { + // 每秒检查一次播放状态和时间 + Stream.periodic(const Duration(seconds: 1)).listen((_) async { + if (isLoggedIn.value) { + await _updatePlaybackState(); + await _updatePlayTime(); + } + }); + } + + /// 更新播放状态 + Future _updatePlaybackState() async { + try { + final result = await _qqMusic.getPlaybackState(); + if (result['success'] == true && result['state'] != null) { + final state = result['state']; + // QQ音乐播放状态:0-停止,1-播放,2-暂停 + isPlaying.value = state == 1; + } + } catch (e) { + // 静默处理错误,避免频繁弹窗 + } + } + + /// 更新播放时间 + Future _updatePlayTime() async { + try { + final result = await _qqMusic.getPlayTime(); + if (result['success'] == true) { + currentPosition.value = result['currentTime'] ?? 0; + totalDuration.value = result['totalTime'] ?? 0; + } + } catch (e) { + // 静默处理错误,避免频繁弹窗 + } + } + + /// 跳转到指定位置(暂时保留,QQ音乐可能不支持) + void seekTo(int position) { + currentPosition.value = position; + // QQ音乐SDK可能不支持跳转功能 + Get.snackbar('提示', '当前版本不支持跳转功能'); + } + + @override + void onClose() { + // 清理资源 + super.onClose(); + } +} diff --git a/lib/modules/qqmusic/models/song_model.dart b/lib/modules/qqmusic/models/song_model.dart new file mode 100644 index 000000000..e93470927 --- /dev/null +++ b/lib/modules/qqmusic/models/song_model.dart @@ -0,0 +1,49 @@ +class SongModel { + final String mid; + final String name; + final String artist; + final String album; + final String? albumPic; + final int duration; + final bool isFavorite; + + SongModel({ + required this.mid, + required this.name, + required this.artist, + required this.album, + this.albumPic, + required this.duration, + this.isFavorite = false, + }); + + factory SongModel.fromJson(Map json) { + return SongModel( + mid: json['mid'] ?? '', + name: json['name'] ?? '', + artist: json['artist'] ?? '', + album: json['album'] ?? '', + albumPic: json['albumPic'], + duration: json['duration'] ?? 0, + isFavorite: json['isFavorite'] ?? false, + ); + } + + Map toJson() { + return { + 'mid': mid, + 'name': name, + 'artist': artist, + 'album': album, + 'albumPic': albumPic, + 'duration': duration, + 'isFavorite': isFavorite, + }; + } + + String get durationText { + final minutes = duration ~/ 60; + final seconds = duration % 60; + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } +} \ No newline at end of file diff --git a/lib/modules/qqmusic/views/qqmusic_view.dart b/lib/modules/qqmusic/views/qqmusic_view.dart new file mode 100644 index 000000000..5cf842e76 --- /dev/null +++ b/lib/modules/qqmusic/views/qqmusic_view.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/qqmusic_controller.dart'; +import '../models/song_model.dart'; + +class QQMusicView extends GetView { + const QQMusicView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('QQ音乐'), + actions: [ + Obx(() => controller.isLoggedIn.value + ? IconButton( + icon: const Icon(Icons.logout), + onPressed: controller.logout, + ) + : IconButton( + icon: const Icon(Icons.login), + onPressed: controller.login, + )), + ], + ), + body: Obx(() { + if (!controller.isLoggedIn.value) { + return _buildLoginView(); + } + return _buildMainView(); + }), + bottomNavigationBar: Obx(() { + if (controller.currentSong.value != null) { + return _buildMiniPlayer(); + } + return const SizedBox.shrink(); + }), + ); + } + + Widget _buildLoginView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.music_note, + size: 100, + color: Colors.blue, + ), + const SizedBox(height: 20), + const Text( + '请先登录QQ音乐', + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: controller.login, + child: const Text('登录QQ音乐'), + ), + ], + ), + ); + } + + Widget _buildMainView() { + return Column( + children: [ + _buildSearchBar(), + Expanded( + child: Obx(() { + if (controller.isSearching.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.searchResults.isEmpty) { + return const Center( + child: Text('搜索歌曲开始您的音乐之旅'), + ); + } + return _buildSearchResults(); + }), + ), + ], + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.all(16), + child: TextField( + decoration: const InputDecoration( + hintText: '搜索歌曲、歌手、专辑', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onSubmitted: controller.searchSongs, + ), + ); + } + + Widget _buildSearchResults() { + return ListView.builder( + itemCount: controller.searchResults.length, + itemBuilder: (context, index) { + final song = controller.searchResults[index]; + return _buildSongItem(song); + }, + ); + } + + Widget _buildSongItem(SongModel song) { + return ListTile( + leading: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: song.albumPic != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + song.albumPic!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.music_note); + }, + ), + ) + : const Icon(Icons.music_note), + ), + title: Text( + song.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${song.artist} • ${song.album}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(song.durationText), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => controller.playSong( + song, + songList: controller.searchResults, + ), + ), + ], + ), + ); + } + + Widget _buildMiniPlayer() { + return Obx(() { + final song = controller.currentSong.value!; + return Container( + height: 80, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + children: [ + // 进度条 + Obx(() => LinearProgressIndicator( + value: controller.totalDuration.value > 0 + ? controller.currentPosition.value / + controller.totalDuration.value + : 0, + backgroundColor: Colors.grey[300], + valueColor: const AlwaysStoppedAnimation(Colors.blue), + )), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // 歌曲信息 + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + song.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), + ), + // 控制按钮 + Row( + children: [ + IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: controller.previousSong, + ), + Obx(() => IconButton( + icon: Icon( + controller.isPlaying.value + ? Icons.pause + : Icons.play_arrow, + ), + onPressed: controller.togglePlayPause, + )), + IconButton( + icon: const Icon(Icons.skip_next), + onPressed: controller.nextSong, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }); + } +} \ No newline at end of file diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index 5a3f35a13..87e5f4f1c 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -104,9 +104,9 @@ class SplashController extends GetxController { } if (isLoggedIn) { await getAppConfig(); - Get.offAllNamed(Routes.pairing); + Get.offAllNamed(Routes.qqmusic); } else { - Get.offAllNamed(Routes.login); // 跳转到登录页 + Get.offAllNamed(Routes.qqmusic); // 跳转到登录页 } } diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 6e5aa0e5e..20fdfb6ab 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -1,4 +1,7 @@ import 'package:get/get.dart'; +import 'package:qq_music/qq_music.dart'; +import 'package:voitrans/modules/qqmusic/bindings/qqmusic_binding.dart'; +import 'package:voitrans/modules/qqmusic/views/qqmusic_view.dart'; import '/modules/ble/bindings/ble_binding.dart'; import '../modules/ble/views/ble_view.dart'; @@ -275,5 +278,10 @@ abstract class AppPages { page: () => const BleView(), binding: BleBinding(), ), + GetPage( + name: Routes.qqmusic, + page: () => const QQMusicView(), + binding: QQMusicBinding(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 6bd4aab32..3a4fc6973 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -61,4 +61,6 @@ abstract class Routes { // ble设备管理 static const ble = '/ble'; + // ble设备管理 + static const qqmusic = '/qqmusic'; } diff --git a/local_plugins/qq_music/.gitignore b/local_plugins/qq_music/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/qq_music/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/local_plugins/qq_music/.metadata b/local_plugins/qq_music/.metadata new file mode 100644 index 000000000..709f51d7e --- /dev/null +++ b/local_plugins/qq_music/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "5c1433509f997e9059d230942bdf83f1b88a179e" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e + base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e + - platform: android + create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e + base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e + - platform: ios + create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e + base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_plugins/qq_music/CHANGELOG.md b/local_plugins/qq_music/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/qq_music/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/qq_music/LICENSE b/local_plugins/qq_music/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/qq_music/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/qq_music/README.md b/local_plugins/qq_music/README.md new file mode 100644 index 000000000..96d01f183 --- /dev/null +++ b/local_plugins/qq_music/README.md @@ -0,0 +1,15 @@ +# qq_music + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/local_plugins/qq_music/analysis_options.yaml b/local_plugins/qq_music/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/qq_music/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/qq_music/android/.gitignore b/local_plugins/qq_music/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/qq_music/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/qq_music/android/build.gradle b/local_plugins/qq_music/android/build.gradle new file mode 100644 index 000000000..1a579ecfd --- /dev/null +++ b/local_plugins/qq_music/android/build.gradle @@ -0,0 +1,79 @@ +group = "com.yunqiinnovation.qq_music" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + // 添加本地AAR仓库 + flatDir { + dirs 'libs' + } + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.yunqiinnovation.qq_music" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + // 合并后的 sourceSets 配置 + sourceSets { + main { + java.srcDirs += "src/main/kotlin" + aidl.srcDirs = ['src/main/aidl'] + } + test { + java.srcDirs += "src/test/kotlin" + } + } + + defaultConfig { + minSdk = 21 + } + + buildFeatures { + aidl true + } + + dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + + // 添加AndroidX AppCompat依赖 + implementation 'androidx.appcompat:appcompat:1.6.1' + + // 添加 Gson 依赖 + implementation 'com.google.code.gson:gson:2.10.1' + + // 添加 ConstraintLayout 依赖 + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + api(name: 'qqmusic-innovation-aidl-api-sdk-1.0.26', ext: 'aar') + } +} diff --git a/local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar b/local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar new file mode 100644 index 000000000..aba449c1e Binary files /dev/null and b/local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar differ diff --git a/local_plugins/qq_music/android/settings.gradle b/local_plugins/qq_music/android/settings.gradle new file mode 100644 index 000000000..135ddb68c --- /dev/null +++ b/local_plugins/qq_music/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'qq_music' diff --git a/local_plugins/qq_music/android/src/main/AndroidManifest.xml b/local_plugins/qq_music/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..64a0aad0b --- /dev/null +++ b/local_plugins/qq_music/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl new file mode 100644 index 000000000..718a193aa --- /dev/null +++ b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl @@ -0,0 +1,15 @@ +// IPlayService.aidl +package com.yunqiinnovation.qq_music; + +// Declare any non-default types here with import statements + +import com.yunqiinnovation.qq_music.IPrint; + +interface IPlayService { + void bindService(); + void startPcmMode(); + void stopPcmMode(); + void resumeOrPause(); + void playNext(); + void setPrintMessageCallback(IPrint callback); +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl new file mode 100644 index 000000000..70d14f41c --- /dev/null +++ b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl @@ -0,0 +1,8 @@ +// IPrintMessage.aidl +package com.yunqiinnovation.qq_music; + +// Declare any non-default types here with import statements + +interface IPrint { + void print(String msg); +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt new file mode 100644 index 000000000..1f1fb70d7 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt @@ -0,0 +1,361 @@ +package com.tencent.qqmusic.api.demo + +import android.os.Bundle +import androidx.annotation.Nullable +import android.util.Log +import com.google.gson.Gson +import com.tencent.qqmusic.third.api.contract.* +import com.tencent.qqmusic.third.api.contract.ErrorCodes.ERROR_API_NOT_INITIALIZED + + +// +// Created by tylertan on 2020-03-19. +// Copyright (c) 2020 Tencent. All rights reserved. +// + +/** + * 对[Methods]中的接口进行基础封装 + * + */ +object ApiSample { + + const val TAG = "ApiSample" + + /** + * 获取接口返回错误码,对应[ErrorCodes]中的错误码列表 + * + * @return 返回[ErrorCodes.ERROR_OK]代表成功,反之为失败 + */ + private fun apiCode(bundle: Bundle?): Int? { + return bundle?.getInt(Keys.API_RETURN_KEY_CODE) + } + + /** + * 获取接口返回错误详细信息 + * + */ + private fun apiError(bundle: Bundle?): String? { + return bundle?.getString(Keys.API_RETURN_KEY_ERROR) + } + + fun hi(api: IQQMusicApi?) { + val bundle = Bundle() + bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) + bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, CommonCmd.AIDL_PLATFORM_TYPE_PHONE) + val result = api?.execute("hi", bundle) + Log.i(TAG, "sayHi ret:" + result!!.getInt(Keys.API_RETURN_KEY_CODE)) + } + + fun playSongMid(api: IQQMusicApi?, ids: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("midList", ids) + api?.executeAsync("playSongMid", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + private fun playSongMidAtIndex(api: IQQMusicApi?, songList: List, index: Int, block: (() -> Unit)) { + val midList = ArrayList() + for (i in songList.indices) { + midList.add(songList[i].mid) + } + + val params = Bundle() + params.putStringArrayList("midList", midList) + params.putInt("index", index) + Log.i(TAG, "[playSongMidAtIndex] 播放歌曲列表... $index,${songList.size}") + api?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun playSongLocalPath(api: IQQMusicApi?, pathList: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("pathList", pathList) + api?.executeAsync("playSongLocalPath", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun playMusic(api: IQQMusicApi?): Int { + val ret = api?.execute("playMusic", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + fun stopMusic(api: IQQMusicApi?): Int { + val ret = api?.execute("stopMusic", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + fun pauseMusic(api: IQQMusicApi?): Int { + val ret = api?.execute("pauseMusic", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + fun resumeMusic(api: IQQMusicApi?): Int { + val ret = api?.execute("resumeMusic", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + fun skipToNext(api: IQQMusicApi?): Int { + val ret = api?.execute("skipToNext", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + fun skipToPrevious(api: IQQMusicApi?): Int { + val ret = api?.execute("skipToPrevious", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + fun getPlaybackState(api: IQQMusicApi?): Int { + val ret = api?.execute("getPlaybackState", null) + return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 + } + + @Nullable + fun getCurrentSong(api: IQQMusicApi?, gson: Gson): Data.Song? { + val ret = api?.execute("getCurrentSong", null) + val curSongJson = ret?.getString(Keys.API_RETURN_KEY_DATA) + return gson.fromJson(curSongJson, Data.Song::class.java) + } + + fun addToFavourite(api: IQQMusicApi?, midList: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("midList", midList) + api?.executeAsync("addToFavourite", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun removeFromFavourite(api: IQQMusicApi?, midList: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("midList", midList) + api?.executeAsync("removeFromFavourite", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun addLocalPathToFavourite(api: IQQMusicApi?, localPathList: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("localPathList", localPathList) + api?.executeAsync("addLocalPathToFavourite", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun removeLocalPathFromFavourite(api: IQQMusicApi?, localPathList: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("localPathList", localPathList) + api?.executeAsync("removeLocalPathFromFavourite", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun isFavouriteMid(api: IQQMusicApi?, midList: ArrayList, block: ((ret: BooleanArray?) -> Unit)) { + val params = Bundle() + params.putStringArrayList("midList", midList) + api?.executeAsync("isFavouriteMid", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) + block(ret) + } + }) + } + + fun isFavouriteLocalPath(api: IQQMusicApi?, localPathList: ArrayList, block: ((ret: BooleanArray?) -> Unit)) { + val params = Bundle() + params.putStringArrayList("localPathList", localPathList) + api?.executeAsync("isFavouriteLocalPath", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) + block(ret) + } + }) + } + + fun registerEventListener(api: IQQMusicApi?, events: ArrayList, block: (() -> Unit)) { + api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() { + override fun onEvent(event: String, extra: Bundle) { + block() + } + }) + } + + fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList, block: (() -> Unit)) { + api?.unregisterEventListener(events, object : IQQMusicApiEventListener.Stub() { + override fun onEvent(event: String, extra: Bundle) { + block() + } + }) + } + + fun playFromChorus(api: IQQMusicApi?, fromChorus: Boolean) { + val params = Bundle() + params.putBoolean("fromChorus", fromChorus) + api?.execute("playFromChorus", params) + } + + @Deprecated("Use playSongMid instead.") + fun playSongId(api: IQQMusicApi?, ids: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("songIdList", ids) + api?.executeAsync("playSongId", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + @Deprecated("Use playSongMidAtIndex instead.") + fun playSongIdAtIndex(api: IQQMusicApi?, ids: ArrayList, block: (() -> Unit)) { + val params = Bundle() + params.putStringArrayList("songIdList", ids) + api?.executeAsync("playSongIdAtIndex", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + block() + } + }) + } + + fun getTotalTime(api: IQQMusicApi?): Long { + val result = api?.execute("getTotalTime", null) + return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 + } + + fun getCurrTime(api: IQQMusicApi?): Long { + val result = api?.execute("getCurrTime", null) + return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 + } + + fun getPlayList(api: IQQMusicApi?, ids: ArrayList, block: ((json: String?) -> Unit)) { + val params = Bundle() + params.putStringArrayList("songIdList", ids) + api?.executeAsync("getPlayList", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle) { + val json = result.getString(Keys.API_RETURN_KEY_DATA) + block(json) + } + }) + } + + fun getFolderList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { + val params = Bundle().apply { + putString("folderId", folderId) + putInt("folderType", folderType) + putInt("page", page) + } + + api?.executeAsync("getFolderList", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + val json = p0?.getString(Keys.API_RETURN_KEY_DATA) + block(json) + } + }) + } + + fun getSongList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { + val params = Bundle().apply { + putString("folderId", folderId) + putInt("folderType", folderType) + putInt("page", page) + } + + api?.executeAsync("getSongList", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + val json = p0?.getString(Keys.API_RETURN_KEY_DATA) + block(json) + } + }) + } + + fun getUserFolderList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { + val params = Bundle().apply { + putString("openId", openId) + putString("openToken", openToken) + putString("folderId", folderId) + putInt("folderType", folderType) + putInt("page", page) + } + + api?.executeAsync("getUserFolderList", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + val json = p0?.getString(Keys.API_RETURN_KEY_DATA) + block(json) + } + }) + } + + fun getUserSongList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { + val params = Bundle().apply { + putString("openId", openId) + putString("openToken", openToken) + putString("folderId", folderId) + putInt("folderType", folderType) + putInt("page", page) + } + + api?.executeAsync("getUserSongList", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + val json = p0?.getString(Keys.API_RETURN_KEY_DATA) + block(json) + } + }) + } + + fun search(api: IQQMusicApi?, block: () -> Unit) { + val params = Bundle().apply { + putString("keyword", "周杰伦") + putInt("searchType", 0) + putBoolean("firstPage", true) + } + + api?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + block() + } + }) + } + + fun voiceShortcut(api: IQQMusicApi?, intent: String, block: (code: Int?) -> Unit) { + val bundle = Bundle() + bundle.apply { + putString("intent", intent) + } + api?.executeAsync("voiceShortcut", bundle, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED + block(code) + } + }) + } + + fun voicePlay(api: IQQMusicApi?, query: String, slotList: ArrayList, block: (code: Int, json: String?) -> Unit) { + val bundle = Bundle() + bundle.apply { + putString("query", query) + putStringArrayList("slotList", slotList) + } + api?.executeAsync("voicePlay", bundle, object : IQQMusicApiCallback.Stub() { + override fun onReturn(p0: Bundle?) { + val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED + val json = p0?.getString(Keys.API_RETURN_KEY_DATA) + block(code, json) + } + }) + } + +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java new file mode 100644 index 000000000..b27ae44ea --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java @@ -0,0 +1,53 @@ +package com.yunqiinnovation.qq_music; + +import com.tencent.qqmusic.third.api.contract.CommonCmd; + +public class Config { + + /** + * 需向QQ音乐发邮件申请,具体参考README + */ + public static final String OPENID_APPID = "2000000921"; + + /** + * 配置接入方自己生成的RSA私钥,由于是Demo,所以直接写在代码中了,真实环境中需要注意私钥泄漏的风险。 + */ + public static final String OPENID_APP_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALWuTayxg0296yfm\n" + + "h8WhunybY1BEFt9fB4qb3TGvB0vkd+oKDoBdiTevtgwSL47h4O0ISNTQqqmVOvk1\n" + + "EQMGcLESBEdHquPX2uOplwmmVWx1un9NYB4JKaHmUkCXOIoiCgmEY052zFY2T+a0\n" + + "RfFiaUTc0wRMynquE8Qp18i23IUBAgMBAAECgYBf/s1D5jd+qE7MMl6i6iVjGaeM\n" + + "Da4YMFoHvvtM+TJdw0Yo/09SPBn4tnr2PdWrJWZ3V91DtU23dQZIDuYCNzDrOkVr\n" + + "93W5Pkqprdbde6qRhWm1VTZVN7h85DStGBzovSxQ6DQNHZFzIriJkfGDjPopmIil\n" + + "awQvMPlS3lLYkzHwAQJBAOWBlshEMHXeqXekjJExwX7YSy2oabWXtllCFbcPprAY\n" + + "MiM8cdNsEhnhrsmdBN4crqTQKJq/ATaYFhzASuIiZ2ECQQDKp17+A3u6q7RZ3Vmb\n" + + "khvAATQpVK5loCOzuyPU7/UGU49AO1DIaPwDvaUqL6PuF9eFrcRHA5AFbBjOgYNn\n" + + "piGhAkEAr5gMo0D4ytYHVxDAs74by071hgNajaN70qLrNlp1MDny8l6mwBnpZ3/y\n" + + "WTMXrFDJljPlPTByXzDRikHdiVLFQQJATMOTpxK4tqA+A+BmBhkVa+jI0nMiUJqV\n" + + "i4py60EuBoR2efU+B3xFIkGfI9jB9vmPCTMma2P9FSC6UNccaJG0oQJBAJYb6Ch9\n" + + "yowGPpY1CShaR+TfauUreJBd4PSpu8mFlYUssp2AXYcJM51RMzrZKXvTTOvwkStc\n" + + "NRCBcPVAYcVUnXo=\n" + + "-----END PRIVATE KEY-----"; + + /** + * 获取纯Base64格式的私钥(去除PEM头尾标记) + * @return 纯Base64格式的私钥字符串 + */ + public static String getBase64PrivateKey() { + return OPENID_APP_PRIVATE_KEY + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); // 移除所有空白字符(包括换行符) + } + + /** + * 配置平台类型,可选值如下: + * {@link CommonCmd#AIDL_PLATFORM_TYPE_PHONE } + * {@link CommonCmd#AIDL_PLATFORM_TYPE_TV} + * {@link CommonCmd#AIDL_PLATFORM_TYPE_CAR} + * {@link CommonCmd#AIDL_PLATFORM_TYPE_PAD} + */ + // 修改这一行:使用CommonCmd中的常量而不是字符串 + public static String BIND_PLATFORM = CommonCmd.AIDL_PLATFORM_TYPE_PHONE; + +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt new file mode 100644 index 000000000..05fe5c51d --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt @@ -0,0 +1,239 @@ +package com.yunqiinnovation.qq_music + +import android.app.Activity +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.yunqiinnovation.qq_music.openid.OpenIDHelper +import com.yunqiinnovation.qq_music.util.QPlayBindHelper +import com.yunqiinnovation.qq_music.util.QQMusicApiWrapper +import com.tencent.qqmusic.third.api.contract.* +import org.json.JSONObject +import org.json.JSONTokener + +/** + * QQ音乐登录管理器 + * 负责处理QQ音乐的登录授权流程 + */ +class QQMusicLoginManager(private val activity: Activity) { + + companion object { + private const val TAG = "QQMusicLoginManager" + private const val BASE_SCHEME = "qqmusicapidemo://" + private const val URI_LOGIN = BASE_SCHEME + "login" + + // 定义错误码常量,替代 LoginExampleActivity 中的常量 + const val LOGIN_FAILED = 1 + } + + private lateinit var qPlayBindHelper: QPlayBindHelper + private var qqMusicApi: IQQMusicApi? = null + private lateinit var qqMusicApiWrapper: QQMusicApiWrapper + private val handler = Handler(Looper.getMainLooper()) + + /** + * 登录结果回调接口 + */ + interface LoginCallback { + fun onSuccess(openId: String, openToken: String, expireTime: String) + fun onError(errorCode: String, errorMessage: String) + } + + /** + * 开始登录流程 + */ + fun startLogin(callback: LoginCallback) { + Log.d(TAG, "开始QQ音乐登录流程") + + // 检查配置 + if (Config.OPENID_APPID.isEmpty() || Config.OPENID_APP_PRIVATE_KEY.isEmpty()) { + callback.onError("CONFIG_ERROR", "请先配置APPID和私钥") + return + } + + // 初始化绑定助手 + qPlayBindHelper = QPlayBindHelper(activity) + + // 开始绑定QQ音乐服务 + bindQQMusicService(callback) + } + + /** + * 绑定QQ音乐服务 + */ + private fun bindQQMusicService(callback: LoginCallback) { + Log.d(TAG, "开始绑定QQ音乐服务") + + qPlayBindHelper.ensureQQMusicBindByStartProcess { success -> + if (success && qPlayBindHelper.isBindQQMusic()) { + Log.d(TAG, "QQ音乐服务绑定成功") + qqMusicApi = qPlayBindHelper.getQQMusicApi()!! + qqMusicApiWrapper = QQMusicApiWrapper(qqMusicApi!!) + + // 检查是否已经授权 + checkAuthStatus(callback) + + } else { + Log.e(TAG, "QQ音乐服务绑定失败") + callback.onError("BIND_FAILED", "绑定QQ音乐服务失败,请确保已安装QQ音乐") + } + } + } + + /** + * 检查授权状态 + */ + private fun checkAuthStatus(callback: LoginCallback) { + Log.d(TAG, "检查授权状态") + + val bundle = Bundle().apply { + putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) + putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) + } + + try { + val result = qqMusicApi?.execute("hi", bundle) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) + + when (code) { + ErrorCodes.ERROR_OK -> { + // 已授权,直接请求登录信息 + Log.d(TAG, "已授权,直接获取登录信息") + requestAuth(callback) + } + ErrorCodes.ERROR_API_NO_PERMISSION -> { + // 需要授权 + Log.d(TAG, "需要授权,启动授权流程") + startAuthProcess(callback) + } + else -> { + Log.e(TAG, "检查授权状态失败: $code") + callback.onError("AUTH_CHECK_FAILED", "检查授权状态失败") + } + } + } catch (e: Exception) { + Log.e(TAG, "检查授权状态异常", e) + callback.onError("AUTH_CHECK_ERROR", "检查授权状态异常: ${e.message}") + } + } + + /** + * 启动授权流程 + */ + private fun startAuthProcess(callback: LoginCallback) { + Log.d(TAG, "启动QQ音乐登录页面") + + // 启动QQ音乐登录 + CommonCmd.loginQQMusic(activity, URI_LOGIN) + + // 等待用户登录完成后进行身份验证 + handler.postDelayed({ + verifyIdentity(callback) + }, 3000) // 给用户足够的登录时间 + } + + /** + * 验证身份 + */ + private fun verifyIdentity(callback: LoginCallback) { + Log.d(TAG, "开始身份验证") + + val time = System.currentTimeMillis() + val nonce = time.toString() + val encryptString = OpenIDHelper.getEncryptString(nonce) + + CommonCmd.verifyCallerIdentity( + activity, + Config.OPENID_APPID, + activity.packageName, + encryptString, + BASE_SCHEME + ) + + // 延迟后请求授权 + handler.postDelayed({ + requestAuth(callback) + }, 2000) + } + + /** + * 请求授权并获取登录信息 + */ + private fun requestAuth(callback: LoginCallback) { + Log.d(TAG, "开始请求授权") + + qqMusicApiWrapper.requestAuthNew( + success = { encrypt -> + Log.d(TAG, "授权成功,开始解密登录信息") + parseLoginResult(encrypt, callback) + }, + failed = { errorCode, errorMsg -> + Log.e(TAG, "授权失败: code=$errorCode, msg=$errorMsg") + + val errorMessage = when (errorCode) { + LOGIN_FAILED -> "登录失败: $errorMsg" + else -> "授权失败: $errorMsg" + } + + callback.onError("AUTH_FAILED", errorMessage) + } + ) + } + + /** + * 解析登录结果 + */ + private fun parseLoginResult(encrypt: String, callback: LoginCallback) { + try { + val qmDecryptString = OpenIDHelper.decryptQQMEncryptString(encrypt) + if (qmDecryptString != null) { + val appParser = JSONTokener(qmDecryptString) + val appDecryptJson = appParser.nextValue() as JSONObject + val sign = appDecryptJson.getString(Keys.API_RETURN_KEY_SIGN) + val returnNonce = appDecryptJson.getString(Keys.API_RETURN_KEY_NONCE) + + // 检查签名 + if (OpenIDHelper.checkQMSign(sign, returnNonce)) { + val openId = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_ID) + val openToken = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_TOKEN) + val expireTime = appDecryptJson.getString(Keys.API_PARAM_KEY_SDK_EXPIRETIME) + + Log.d(TAG, "登录成功: openId=$openId") + + handler.post { + callback.onSuccess(openId, openToken, expireTime) + } + } else { + Log.e(TAG, "签名验证失败") + handler.post { + callback.onError("SIGN_ERROR", "签名验证失败") + } + } + } else { + Log.e(TAG, "解密失败") + handler.post { + callback.onError("DECRYPT_ERROR", "解密失败") + } + } + } catch (e: Exception) { + Log.e(TAG, "解析登录结果失败", e) + handler.post { + callback.onError("PARSE_ERROR", "解析登录结果失败: ${e.message}") + } + } + } + + /** + * 清理资源 + */ + fun cleanup() { + try { + if (::qPlayBindHelper.isInitialized) { + qPlayBindHelper.unBindQQMusic() + } + } catch (e: Exception) { + Log.e(TAG, "清理资源失败", e) + } + } +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt new file mode 100644 index 000000000..5482419bc --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt @@ -0,0 +1,495 @@ +package com.yunqiinnovation.qq_music + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonParser +import com.tencent.qqmusic.third.api.contract.* +import android.os.Handler +import android.os.Looper + +class QQMusicManager(private val activity: Activity) { + companion object { + const val TAG = "QQMusicManager" + } + + private var qqmusicApi: IQQMusicApi? = null + private var isBindQQMusicService = false + private val gson = Gson() + + // 回调接口 + interface SearchCallback { + fun onSuccess(songs: List>) + fun onError(errorCode: Int, errorMessage: String) + } + + interface PlayCallback { + fun onSuccess() + fun onError(errorCode: Int, errorMessage: String) + } + + interface InfoCallback { + fun onSuccess(data: Map) + fun onError(errorCode: Int, errorMessage: String) + } + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "QQ音乐服务连接成功") + qqmusicApi = IQQMusicApi.Stub.asInterface(service) + isBindQQMusicService = true + + // 初始化SDK + CommonCmd.init(Config.BIND_PLATFORM) + + // 发送hi消息 + sayHi() + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "QQ音乐服务连接断开") + qqmusicApi = null + isBindQQMusicService = false + } + } + + init { + bindQQMusicApiService() + } + + private fun sayHi() { + if (!isBindQQMusicService || qqmusicApi == null) return + + val bundle = Bundle() + bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) + bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) + + val result = qqmusicApi?.execute("hi", bundle) + Log.d(TAG, "sayHi ret:" + result?.getInt(Keys.API_RETURN_KEY_CODE)) + } + + private fun bindQQMusicApiService() { + Log.d(TAG, "开始绑定QQ音乐服务,平台类型: ${Config.BIND_PLATFORM}") + + val intent = when (Config.BIND_PLATFORM) { + CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> { + Intent("com.tencent.qqmusic.third.api.QQMusicApiService").apply { + `package` = "com.tencent.qqmusic" + } + } + CommonCmd.AIDL_PLATFORM_TYPE_CAR -> { + Intent("com.tencent.qqmusiccar.third.api.QQMusicApiService").apply { + `package` = "com.tencent.qqmusiccar" + } + } + CommonCmd.AIDL_PLATFORM_TYPE_TV -> { + Intent("com.tencent.qqmusictv.third.api.QQMusicApiService").apply { + `package` = "com.tencent.qqmusictv" + } + } + else -> { + Log.e(TAG, "不支持的平台类型: ${Config.BIND_PLATFORM}") + return + } + } + + Log.d(TAG, "绑定Intent: action=${intent.action}, package=${intent.`package`}") + + try { + val bindResult = activity.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + Log.d(TAG, "bindService结果: $bindResult") + + if (!bindResult) { + Log.e(TAG, "绑定QQ音乐服务失败,尝试启动QQ音乐进程") + // 尝试启动QQ音乐进程 + CommonCmd.startQQMusicProcess(activity, activity.packageName) + + // 延迟重试绑定 + Handler(Looper.getMainLooper()).postDelayed({ + Log.d(TAG, "重试绑定QQ音乐服务") + activity.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + }, 2000) + } + } catch (e: Exception) { + Log.e(TAG, "绑定QQ音乐服务异常", e) + } + } + + /** + * 搜索歌曲 + * @param keyword 搜索关键词 + * @param searchType 搜索类型,参考Data.SearchType + * @param callback 回调 + */ + fun searchSongs( + keyword: String, + searchType: Int = Data.SearchType.SEARCH_TYPE_SONG, + callback: SearchCallback + ) { + Log.d(TAG, "searchSongs: keyword=$keyword, searchType=$searchType") + + if (!isBindQQMusicService || qqmusicApi == null) { + Log.e(TAG, "QQ音乐服务未连接") + callback.onError(-1, "QQ音乐服务未连接") + return + } + + // 先测试服务连接状态 + try { + val testBundle = Bundle() + testBundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) + testBundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) + + val testResult = qqmusicApi?.execute("hi", testBundle) + val testCode = testResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + Log.d(TAG, "服务连接测试结果: $testCode") + + if (testCode != ErrorCodes.ERROR_OK) { + Log.e(TAG, "QQ音乐服务连接异常: $testCode") + val testErrorMessage = when (testCode) { + ErrorCodes.ERROR_API_NO_PERMISSION -> "没有权限访问QQ音乐服务" + ErrorCodes.ERROR_API_NOT_INITIALIZED -> "QQ音乐API未初始化" + else -> "QQ音乐服务连接异常 (错误码: $testCode)" + } + callback.onError(testCode, testErrorMessage) + return + } + } catch (e: Exception) { + Log.e(TAG, "测试服务连接失败", e) + callback.onError(-1, "测试服务连接失败: ${e.message}") + return + } + + val params = Bundle().apply { + putInt("searchType", searchType) + putString("keyword", keyword) + putBoolean("firstPage", true) + } + + Log.d(TAG, "调用QQ音乐搜索API,参数: searchType=$searchType, keyword=$keyword") + + try { + // 直接调用executeAsync,不接收返回值 + qqmusicApi?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle?) { + Log.d(TAG, "搜索API返回结果") + + if (result == null) { + Log.e(TAG, "搜索返回结果为null") + callback.onError(-1, "搜索返回结果为null") + return + } + + val code = result.getInt(Keys.API_RETURN_KEY_CODE, -1) + Log.d(TAG, "搜索返回码: $code") + + if (code == ErrorCodes.ERROR_OK) { + try { + val dataJson = result.getString(Keys.API_RETURN_KEY_DATA) + Log.d(TAG, "搜索数据: $dataJson") + + if (dataJson != null && dataJson.isNotEmpty()) { + val array = JsonParser().parse(dataJson).asJsonArray + val songs = mutableListOf>() + + for (elem in array) { + val song = gson.fromJson(elem, Data.Song::class.java) + songs.add(mapOf( + "mid" to song.mid, + "id" to song.id, + "name" to song.title, + "singer" to song.singer?.title, + "album" to song.album?.title, + "duration" to song.duration, + "pic" to song.album?.coverUri + )) + } + Log.d(TAG, "解析完成,共 ${songs.size} 首歌曲") + callback.onSuccess(songs) + } else { + Log.e(TAG, "搜索结果数据为空") + callback.onError(code, "搜索结果数据为空") + } + } catch (e: Exception) { + Log.e(TAG, "解析搜索结果异常", e) + callback.onError(-1, "解析搜索结果失败: ${e.message}") + } + } else { + val error = result.getString(Keys.API_RETURN_KEY_ERROR) ?: "搜索失败" + Log.e(TAG, "搜索失败: code=$code, error=$error") + + // 根据实际可用的错误码提供具体的错误信息 + val errorMessage = when (code) { + ErrorCodes.ERROR_API_NO_PERMISSION -> "没有搜索权限,请检查应用授权" + ErrorCodes.ERROR_API_NOT_INITIALIZED -> "QQ音乐API未初始化,请重新登录" + else -> "搜索失败: $error (错误码: $code)" + } + callback.onError(code, errorMessage) + } + } + }) + + Log.d(TAG, "executeAsync调用完成,等待回调") + + } catch (e: Exception) { + Log.e(TAG, "调用搜索API异常", e) + callback.onError(-1, "调用搜索API异常: ${e.message}") + } + } + + /** + * 播放歌曲列表 + * @param songMids 歌曲MID列表 + * @param index 开始播放的索引 + * @param callback 回调 + */ + fun playSongs( + songMids: List, + index: Int = 0, + callback: PlayCallback + ) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val params = Bundle().apply { + putStringArrayList("midList", ArrayList(songMids)) + putInt("index", index) + } + + // 直接调用executeAsync,不接收返回值 + qqmusicApi?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() { + override fun onReturn(result: Bundle?) { + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + val error = result?.getString(Keys.API_RETURN_KEY_ERROR) ?: "播放失败" + callback.onError(code, error) + } + } + }) + } + + /** + * 播放音乐 + */ + fun playMusic(callback: PlayCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("playMusic", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + callback.onError(code, "播放失败") + } + } + + /** + * 暂停音乐 + */ + fun pauseMusic(callback: PlayCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("pauseMusic", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + callback.onError(code, "暂停失败") + } + } + + /** + * 恢复播放 + */ + fun resumeMusic(callback: PlayCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("resumeMusic", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + callback.onError(code, "恢复播放失败") + } + } + + /** + * 停止音乐 + */ + fun stopMusic(callback: PlayCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("stopMusic", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + callback.onError(code, "停止失败") + } + } + + /** + * 下一首 + */ + fun skipToNext(callback: PlayCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("skipToNext", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + callback.onError(code, "切换下一首失败") + } + } + + /** + * 上一首 + */ + fun skipToPrevious(callback: PlayCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("skipToPrevious", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + callback.onSuccess() + } else { + callback.onError(code, "切换上一首失败") + } + } + + /** + * 获取播放状态 + */ + fun getPlaybackState(callback: InfoCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("getPlaybackState", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + val state = result?.getInt(Keys.API_RETURN_KEY_DATA) ?: 0 + callback.onSuccess(mapOf("state" to state)) + } else { + callback.onError(code, "获取播放状态失败") + } + } + + /** + * 获取当前播放歌曲 + */ + fun getCurrentSong(callback: InfoCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + val result = qqmusicApi?.execute("getCurrentSong", null) + val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + if (code == ErrorCodes.ERROR_OK) { + try { + val curSongJson = result?.getString(Keys.API_RETURN_KEY_DATA) + if (curSongJson != null) { + val song = gson.fromJson(curSongJson, Data.Song::class.java) + val songMap = mapOf( + "mid" to song.mid, + "id" to song.id, + "name" to song.title, + "singer" to song.singer?.title, + "album" to song.album?.title, + "duration" to song.duration, + "pic" to song.album?.coverUri + ) + callback.onSuccess(songMap) + } else { + callback.onError(-1, "当前没有播放歌曲") + } + } catch (e: Exception) { + Log.e(TAG, "解析当前歌曲信息异常", e) + callback.onError(-1, "解析歌曲信息失败: ${e.message}") + } + } else { + callback.onError(code, "获取当前歌曲失败") + } + } + + /** + * 获取播放时长 + */ + fun getPlayTime(callback: InfoCallback) { + if (!isBindQQMusicService || qqmusicApi == null) { + callback.onError(-1, "QQ音乐服务未连接") + return + } + + try { + // 获取当前播放时间 + val currResult = qqmusicApi?.execute("getCurrTime", null) + val currCode = currResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + + // 获取总时长 + val totalResult = qqmusicApi?.execute("getTotalTime", null) + val totalCode = totalResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 + + if (currCode == ErrorCodes.ERROR_OK && totalCode == ErrorCodes.ERROR_OK) { + val currentTime = (currResult?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 + val totalTime = (totalResult?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 + + callback.onSuccess(mapOf( + "currentTime" to currentTime, + "totalTime" to totalTime + )) + } else { + callback.onError(-1, "获取播放时长失败") + } + } catch (e: Exception) { + Log.e(TAG, "获取播放时长异常", e) + callback.onError(-1, "获取播放时长异常: ${e.message}") + } + } + + /** + * 清理资源 + */ + fun cleanup() { + try { + if (isBindQQMusicService) { + activity.unbindService(serviceConnection) + isBindQQMusicService = false + } + } catch (e: Exception) { + Log.e(TAG, "清理资源异常", e) + } + qqmusicApi = null + } +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt new file mode 100644 index 000000000..02ee2f5fd --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt @@ -0,0 +1,393 @@ +package com.yunqiinnovation.qq_music + +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.app.Activity +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +// 添加这行导入 +import com.tencent.qqmusic.third.api.contract.* + +/** QqMusicPlugin */ +class QqMusicPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { + private lateinit var channel: MethodChannel + private var activity: Activity? = null + private var loginManager: QQMusicLoginManager? = null + private var musicManager: QQMusicManager? = null + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.qq_music") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "login" -> { + handleLogin(result) + } + "searchSongs" -> { + handleSearchSongs(call, result) + } + "playSongs" -> { + handlePlaySongs(call, result) + } + "playMusic" -> { + handlePlayMusic(result) + } + "pauseMusic" -> { + handlePauseMusic(result) + } + "resumeMusic" -> { + handleResumeMusic(result) + } + "stopMusic" -> { + handleStopMusic(result) + } + "skipToNext" -> { + handleSkipToNext(result) + } + "skipToPrevious" -> { + handleSkipToPrevious(result) + } + "getPlaybackState" -> { + handleGetPlaybackState(result) + } + "getCurrentSong" -> { + handleGetCurrentSong(result) + } + "getPlayTime" -> { + handleGetPlayTime(result) + } + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + else -> { + result.notImplemented() + } + } + } + + private fun handleLogin(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + // 创建登录管理器 + loginManager = QQMusicLoginManager(currentActivity) + + // 开始登录 + loginManager?.startLogin(object : QQMusicLoginManager.LoginCallback { + override fun onSuccess(openId: String, openToken: String, expireTime: String) { + // 确保在主线程中执行回调 + Handler(Looper.getMainLooper()).post { + val loginResult = mapOf( + "success" to true, + "openId" to openId, + "openToken" to openToken, + "expireTime" to expireTime + ) + Log.d("QqMusicPlugin", "登录成功回调: $loginResult") + result.success(loginResult) + } + } + + override fun onError(errorCode: String, errorMessage: String) { + // 确保在主线程中执行回调 + Handler(Looper.getMainLooper()).post { + Log.e("QqMusicPlugin", "登录失败回调: $errorCode - $errorMessage") + result.error(errorCode, errorMessage, null) + } + } + }) + } + + private fun handleSearchSongs(call: MethodCall, result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + val keyword = call.argument("keyword") + val searchType = call.argument("searchType") ?: Data.SearchType.SEARCH_TYPE_SONG + + if (keyword == null) { + result.error("INVALID_ARGUMENT", "keyword is required", null) + return + } + + Log.d("QqMusicPlugin", "开始搜索: keyword=$keyword, searchType=$searchType") + + ensureMusicManager(currentActivity) + musicManager?.searchSongs(keyword, searchType, object : QQMusicManager.SearchCallback { + override fun onSuccess(songs: List>) { + // 确保在主线程中执行回调 + Handler(Looper.getMainLooper()).post { + Log.d("QqMusicPlugin", "搜索成功: 找到 ${songs.size} 首歌曲") + result.success(mapOf( + "success" to true, + "songs" to songs + )) + } + } + + override fun onError(errorCode: Int, errorMessage: String) { + // 确保在主线程中执行回调 + Handler(Looper.getMainLooper()).post { + Log.e("QqMusicPlugin", "搜索失败: code=$errorCode, message=$errorMessage") + result.error("SEARCH_FAILED", errorMessage, null) + } + } + }) + } + + private fun handlePlaySongs(call: MethodCall, result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + val songMids = call.argument>("songMids") + val index = call.argument("index") ?: 0 + + if (songMids == null || songMids.isEmpty()) { + result.error("INVALID_ARGUMENT", "songMids is required", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.playSongs(songMids, index, object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("PLAY_FAILED", errorMessage, null) + } + }) + } + + private fun handlePlayMusic(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.playMusic(object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("PLAY_FAILED", errorMessage, null) + } + }) + } + + private fun handlePauseMusic(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.pauseMusic(object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("PAUSE_FAILED", errorMessage, null) + } + }) + } + + private fun handleResumeMusic(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.resumeMusic(object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("RESUME_FAILED", errorMessage, null) + } + }) + } + + private fun handleStopMusic(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.stopMusic(object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("STOP_FAILED", errorMessage, null) + } + }) + } + + private fun handleSkipToNext(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.skipToNext(object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("SKIP_FAILED", errorMessage, null) + } + }) + } + + private fun handleSkipToPrevious(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.skipToPrevious(object : QQMusicManager.PlayCallback { + override fun onSuccess() { + result.success(mapOf("success" to true)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("SKIP_FAILED", errorMessage, null) + } + }) + } + + private fun handleGetPlaybackState(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.getPlaybackState(object : QQMusicManager.InfoCallback { + override fun onSuccess(data: Map) { + result.success(mapOf( + "success" to true, + "state" to data["state"] + )) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("GET_STATE_FAILED", errorMessage, null) + } + }) + } + + private fun handleGetCurrentSong(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.getCurrentSong(object : QQMusicManager.InfoCallback { + override fun onSuccess(data: Map) { + result.success(mapOf( + "success" to true, + "song" to data + )) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("GET_SONG_FAILED", errorMessage, null) + } + }) + } + + private fun handleGetPlayTime(result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + ensureMusicManager(currentActivity) + musicManager?.getPlayTime(object : QQMusicManager.InfoCallback { + override fun onSuccess(data: Map) { + result.success(mapOf( + "success" to true, + "totalTime" to data["totalTime"], + "currentTime" to data["currentTime"] + )) + } + + override fun onError(errorCode: Int, errorMessage: String) { + result.error("GET_TIME_FAILED", errorMessage, null) + } + }) + } + + private fun ensureMusicManager(activity: Activity) { + if (musicManager == null) { + musicManager = QQMusicManager(activity) + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + loginManager?.cleanup() + loginManager = null + musicManager?.cleanup() + musicManager = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + loginManager?.cleanup() + loginManager = null + musicManager?.cleanup() + musicManager = null + } +} diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java new file mode 100644 index 000000000..4fb043f93 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java @@ -0,0 +1,64 @@ +package com.yunqiinnovation.qq_music.openid; + +import android.util.Base64; + +import com.yunqiinnovation.qq_music.Config; +import com.tencent.qqmusic.third.api.contract.Keys; + +import org.json.JSONObject; + +public class OpenIDHelper { + + private static final String QQMusicPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrp4sMcJjY9hb2J3sHWlwIEBrJlw2Cimv+rZAQmR8V3EI+0PUK14pL8OcG7CY79li30IHwYGWwUapADKA01nKgNeq7+rSciMYZv6ByVq+ocxKY8az78HwIppwxKWpQ+ziqYavvfE5+iHIzAc8RvGj9lL6xx1zhoPkdaA0agAyuMQIDAQAB"; + + + public static String getEncryptString(String nonce) { + if (nonce == null || nonce.isEmpty()) { + return null; + } + + try { + //1.使用App私钥签名 - 修改这里,使用Base64格式的私钥 + String signString = RSAUtils.sign(nonce.getBytes(), Config.getBase64PrivateKey()); + + JSONObject signJson = new JSONObject(); + signJson.put(Keys.API_RETURN_KEY_NONCE, nonce); + signJson.put(Keys.API_RETURN_KEY_SIGN, signString); + signJson.put(Keys.API_RETURN_KEY_CALLBACK_URL, "qqmusicapidemo://"); + String sourceString = signJson.toString(); + + //2. 使用Q音公钥加密(随机数+签名) + byte[] encryptData = RSAUtils.encryptByPublicKey(sourceString.getBytes(), QQMusicPublicKey); + if (encryptData == null) { + return null; + } + return Base64.encodeToString(encryptData, Base64.DEFAULT); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public static String decryptQQMEncryptString(String qmEncryptString) { + try { + byte[] qmEncryptData = Base64.decode(qmEncryptString, Base64.DEFAULT); + //7.使用App私钥解密 - 修改这里,使用Base64格式的私钥 + byte[] decryptData = RSAUtils.decryptByPrivateKey(qmEncryptData, Config.getBase64PrivateKey()); + if (decryptData != null) { + return new String(decryptData); + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public static boolean checkQMSign(String sign, String nonce) { + try { + return RSAUtils.verify(nonce.getBytes(), QQMusicPublicKey, sign); + } catch (Exception e) { + e.printStackTrace(); + } + return false; + } +} diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java new file mode 100644 index 000000000..0a0704fe5 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java @@ -0,0 +1,314 @@ +package com.yunqiinnovation.qq_music.openid; + +import android.util.Base64; + +import java.io.ByteArrayOutputStream; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.Cipher; + +public class RSAUtils { + /** + * 加密算法RSA + */ + public static final String KEY_ALGORITHM = "RSA"; + + /** + * 签名算法 + */ + public static final String SIGNATURE_ALGORITHM = "SHA256withRSA";//"MD5withRSA"; + + /** + * 获取公钥的key + */ + private static final String PUBLIC_KEY = "RSAPublicKey"; + + /** + * 获取私钥的key + */ + private static final String PRIVATE_KEY = "RSAPrivateKey"; + + private static final String CIHPER_KEY = "RSA/ECB/PKCS1Padding";// + + /** + * RSA最大加密明文大小 + */ + private static final int MAX_ENCRYPT_BLOCK = 117; + + /** + * RSA最大解密密文大小 + */ + private static final int MAX_DECRYPT_BLOCK = 128; + + /** + * 生成密钥对(公钥和私钥) + * + * @return + * @throws Exception + */ + public static Map genKeyPair() throws Exception { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM); + keyPairGen.initialize(1024); + KeyPair keyPair = keyPairGen.generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + Map keyMap = new HashMap<>(2); + keyMap.put(PUBLIC_KEY, publicKey); + keyMap.put(PRIVATE_KEY, privateKey); + return keyMap; + } + + /** + * 用私钥对信息生成数字签名 + * + * @param data 已加密数据 + * @param privateKey 私钥(BASE64编码) + * @return + * @throws Exception + */ + public static String sign(byte[] data, String privateKey) throws Exception { + + byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); + PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + PrivateKey privateK = keyFactory.generatePrivate(pkcs8KeySpec); + Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); + signature.initSign(privateK); + signature.update(data); + return Base64.encodeToString(signature.sign(), Base64.DEFAULT); + } + + /** + * 校验数字签名 + * + * @param data 已加密数据 + * @param publicKey 公钥(BASE64编码) + * @param sign 数字签名 + * @return + * @throws Exception + */ + public static boolean verify(byte[] data, String publicKey, String sign) + throws Exception { + byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + PublicKey publicK = keyFactory.generatePublic(keySpec); + Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); + signature.initVerify(publicK); + signature.update(data); + return signature.verify(Base64.decode(sign, Base64.DEFAULT)); + } + + /** + * 私钥解密 + * + * @param encryptedData 已加密数据 + * @param privateKey 私钥(BASE64编码) + * @return + * @throws Exception + */ + public static byte[] decryptByPrivateKey(byte[] encryptedData, String privateKey) + throws Exception { + byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); + PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + Key privateK = keyFactory.generatePrivate(pkcs8KeySpec); +// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm()); + Cipher cipher = Cipher.getInstance(CIHPER_KEY); + + cipher.init(Cipher.DECRYPT_MODE, privateK); + int inputLen = encryptedData.length; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int offSet = 0; + byte[] cache; + int i = 0; + // 对数据分段解密 + while (inputLen - offSet > 0) { + if (inputLen - offSet > MAX_DECRYPT_BLOCK) { + cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK); + } else { + cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet); + } + out.write(cache, 0, cache.length); + i++; + offSet = i * MAX_DECRYPT_BLOCK; + } + byte[] decryptedData = out.toByteArray(); + out.close(); + return decryptedData; + } + + public static String testDecryptByJavaPrivKey(String privkey_from_java, String encryptedString) throws Exception { + //加解密类 + Cipher cipher = Cipher.getInstance("RSA");//Cipher.getInstance("RSA/ECB/PKCS1Padding"); + // G0语言通过java产生的公钥加密得到的加密后的字符串 + byte[] enBytes = Base64.decode(encryptedString, Base64.DEFAULT); + //通过密钥字符串得到密钥 + + PrivateKey privateKey = testGetPrivateKey(privkey_from_java); + + + //解密 + cipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] deBytes = cipher.doFinal(enBytes); + return new String(deBytes); + + } + + public static PrivateKey testGetPrivateKey(String key) throws Exception { + byte[] keyBytes; + keyBytes = Base64.decode(key, Base64.DEFAULT); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } + + /** + * 公钥解密 + * + * @param encryptedData 已加密数据 + * @param publicKey 公钥(BASE64编码) + * @return + * @throws Exception + */ + public static byte[] decryptByPublicKey(byte[] encryptedData, String publicKey) + throws Exception { + byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); + X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + Key publicK = keyFactory.generatePublic(x509KeySpec); +// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm()); + Cipher cipher = Cipher.getInstance(CIHPER_KEY); + cipher.init(Cipher.DECRYPT_MODE, publicK); + int inputLen = encryptedData.length; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int offSet = 0; + byte[] cache; + int i = 0; + // 对数据分段解密 + while (inputLen - offSet > 0) { + if (inputLen - offSet > MAX_DECRYPT_BLOCK) { + cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK); + } else { + cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet); + } + out.write(cache, 0, cache.length); + i++; + offSet = i * MAX_DECRYPT_BLOCK; + } + byte[] decryptedData = out.toByteArray(); + out.close(); + return decryptedData; + } + + /** + * 公钥加密 + * + * @param data 源数据 + * @param publicKey 公钥(BASE64编码) + * @return + * @throws Exception + */ + public static byte[] encryptByPublicKey(byte[] data, String publicKey) + throws Exception { + byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); + X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + Key publicK = keyFactory.generatePublic(x509KeySpec); + // 对数据加密 +// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm()); + Cipher cipher = Cipher.getInstance(CIHPER_KEY); + cipher.init(Cipher.ENCRYPT_MODE, publicK); + int inputLen = data.length; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int offSet = 0; + byte[] cache; + int i = 0; + // 对数据分段加密 + while (inputLen - offSet > 0) { + if (inputLen - offSet > MAX_ENCRYPT_BLOCK) { + cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK); + } else { + cache = cipher.doFinal(data, offSet, inputLen - offSet); + } + out.write(cache, 0, cache.length); + i++; + offSet = i * MAX_ENCRYPT_BLOCK; + } + byte[] encryptedData = out.toByteArray(); + out.close(); + return encryptedData; + } + + /** + * 私钥加密 + * + * @param data 源数据 + * @param privateKey 私钥(BASE64编码) + * @return + * @throws Exception + */ + public static byte[] encryptByPrivateKey(byte[] data, String privateKey) + throws Exception { + byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); + PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + Key privateK = keyFactory.generatePrivate(pkcs8KeySpec); +// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm()); + Cipher cipher = Cipher.getInstance(CIHPER_KEY); + cipher.init(Cipher.ENCRYPT_MODE, privateK); + int inputLen = data.length; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int offSet = 0; + byte[] cache; + int i = 0; + // 对数据分段加密 + while (inputLen - offSet > 0) { + if (inputLen - offSet > MAX_ENCRYPT_BLOCK) { + cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK); + } else { + cache = cipher.doFinal(data, offSet, inputLen - offSet); + } + out.write(cache, 0, cache.length); + i++; + offSet = i * MAX_ENCRYPT_BLOCK; + } + byte[] encryptedData = out.toByteArray(); + out.close(); + return encryptedData; + } + + /** + * 获取私钥 + * + * @param keyMap 密钥对 + * @return + */ + public static String getPrivateKey(Map keyMap) { + Key key = (Key) keyMap.get(PRIVATE_KEY); + return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); + } + + /** + * 获取公钥 + * + * @param keyMap 密钥对 + * @return + */ + public static String getPublicKey(Map keyMap) { + Key key = (Key) keyMap.get(PUBLIC_KEY); + return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); + } +} diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt new file mode 100644 index 000000000..7a8a868e0 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt @@ -0,0 +1,253 @@ +package com.yunqiinnovation.qq_music.pcm + +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.AudioTrack.* +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.Process +import android.util.Log +import com.yunqiinnovation.qq_music.util.BitConverter +import java.io.DataInputStream +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.util.concurrent.ConcurrentLinkedQueue + + +class AudioTrackManager { + companion object { + private const val TAG = "AudioTrackManager" + private const val LOG_LOOP = true + + //音频流类型 + private const val mStreamType = AudioManager.STREAM_MUSIC + + //指定采样率 (MediaRecoder 的采样率通常是8000Hz AAC的通常是44100Hz。 设置采样率为44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置) + private const val mSampleRateInHz = 44100 + + //指定捕获音频的声道数目。在AudioFormat类中指定用于此的常量 + private const val mChannelConfig = AudioFormat.CHANNEL_IN_STEREO //STEREO立体声 + + //指定音频量化位数 ,在AudioFormaat类中指定了以下各种可能的常量。通常我们选择ENCODING_PCM_16BIT和ENCODING_PCM_8BIT PCM代表的是脉冲编码调制,它实际上是原始音频样本。 + //因此可以设置每个样本的分辨率为16位或者8位,16位将占用更多的空间和处理能力,表示的音频也更加接近真实。 + private const val mAudioFormat = AudioFormat.ENCODING_PCM_FLOAT + + //STREAM的意思是由用户在应用程序通过write方式把数据一次一次得写到audiotrack中。这个和我们在socket中发送数据一样, + // 应用层从某个地方获取数据,例如通过编解码得到PCM数据,然后write到audiotrack。 + private const val mMode = AudioTrack.MODE_STREAM + } + + private var mAudioTrack: AudioTrack? = null + + private var mInputStream: InputStream? = null //播放的数据流 + private var mPlayerThread: Thread? = null + + //指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。 + private var mMinBufferSize = 0 + + var printMessageCallback: ((String) -> Unit)? = null + var enterForeground: ((Boolean) -> Unit)? = null + + var isFloatPcm = true + + fun initData(sampleRateInHz: Int = mSampleRateInHz, channelConfig: Int = mChannelConfig, audioFormat: Int = mAudioFormat) { + Log.d(TAG, "[initData】$sampleRateInHz, $channelConfig, $audioFormat") + //根据采样率,采样精度,单双声道来得到frame的大小。 + mMinBufferSize = AudioTrack.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) //计算最小缓冲区 + //注意,按照数字音频的知识,这个算出来的是一秒钟buffer的大小。 + //创建AudioTrack + mAudioTrack = AudioTrack(mStreamType, sampleRateInHz, channelConfig, audioFormat, mMinBufferSize, mMode) + isFloatPcm = audioFormat == AudioFormat.ENCODING_PCM_FLOAT + play() + } + + /** + * 销毁线程方法 + */ + private fun destroyThread() { + try { + if (null != mPlayerThread && Thread.State.RUNNABLE == mPlayerThread!!.state) { + try { + Thread.sleep(500) + mPlayerThread!!.interrupt() + } catch (e: Exception) { + mPlayerThread = null + } + } + mPlayerThread = null + } catch (e: Exception) { + e.printStackTrace() + } finally { + mPlayerThread = null + } + } + + /** + * 启动播放线程 + */ + private fun startThread() { + destroyThread() + if (mPlayerThread == null) { + mPlayerThread = Thread(streamPlayerRunnable) + mPlayerThread!!.start() + } + } + + private val streamPlayerRunnable = Runnable { + try { + //设置线程的优先级 + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) + Log.d(TAG, "streamPlayerRunnable") + + val tempBuffer = ByteArray(mMinBufferSize * 4) + var readCount = 0 + while (true) { + if (LOG_LOOP) { + //Log.d(TAG, "while true") + } + if (mInputStream!!.available() <= 0) { + if (LOG_LOOP) { + //Log.d(TAG, "sleep") + } + printMessageCallback?.invoke("Play thread sleep \n PlayState:${mAudioTrack?.playState?.playStateToString()}") + Thread.sleep(500) + continue + } + + readCount = mInputStream!!.read(tempBuffer) + if (LOG_LOOP) { + Log.d(TAG, "readCount:$readCount") + } + if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) { + continue + } + if (readCount != 0 && readCount != -1) { + //判断AudioTrack未初始化,停止播放的时候释放了,状态就为STATE_UNINITIALIZED + if (mAudioTrack!!.state == AudioTrack.STATE_UNINITIALIZED) { + initData() + } + + printMessageCallback?.invoke("AudioTrack write $readCount Bytes\n PlayState:${mAudioTrack?.playState?.playStateToString()}") + + if (isFloatPcm) { + val size = readCount / 4 + val audioData = FloatArray(size) + for (i in 0 until size) { + // 框架地址:https://github.com/smartdevicelink/sdl_java_suite/blob/master/base/src/main/java/com/smartdevicelink/util/BitConverter.java + val bitInt = BitConverter.intFromByteArray(tempBuffer, i * 4) + val fl = java.lang.Float.intBitsToFloat(bitInt) + audioData[i] = fl + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val ret = mAudioTrack?.write(audioData, 0, audioData.size, WRITE_BLOCKING) + Log.d(TAG, "ret=$ret") + } + } else { + mAudioTrack!!.write(tempBuffer, 0, readCount) + } + + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun Int.playStateToString(): String { + return when (this) { + PLAYSTATE_STOPPED -> { + "PLAYSTATE_STOPPED" + } + PLAYSTATE_PAUSED -> { + "PLAYSTATE_PAUSED" + } + PLAYSTATE_PLAYING -> { + "PLAYSTATE_PLAYING" + } + else -> { + "unknown" + } + } + } + + /** + * 播放文件 + * + * @param path + * @throws Exception + */ + @Throws(Exception::class) + private fun setPath(path: String) { + val file = File(path) + mInputStream = DataInputStream(FileInputStream(file)) + } + + /** + * 启动播放 + * + * @param path + */ + fun startPlay(path: String) { + try { + //AudioTrack未初始化 + setPath(path) + startThread() + } catch (e: Exception) { + Log.d(TAG, "startPlay: Exception$e") + } + } + + fun startPlay(inputStream: InputStream?) { + initData() + try { + mInputStream = DataInputStream(inputStream) + startThread() + } catch (e: Exception) { + Log.d(TAG, "startPlay: Exception$e") + } + } + + fun startPlayByFileDescriptor(fileDescriptor: ParcelFileDescriptor) { + mInputStream = ParcelFileDescriptor.AutoCloseInputStream(fileDescriptor) + startThread() + } + + /** + * 停止播放 + */ + fun stopPlay() { + try { + destroyThread() //销毁线程 + if (mAudioTrack != null) { + if (mAudioTrack!!.state == AudioRecord.STATE_INITIALIZED) { //初始化成功 + mAudioTrack!!.stop() //停止播放 + } + if (mAudioTrack != null) { + mAudioTrack!!.release() //释放audioTrack资源 + } + mAudioTrack = null + } + if (mInputStream != null) { + mInputStream!!.close() //关闭数据输入流 + } + } catch (e: Exception) { + e.printStackTrace() + } + enterForeground?.invoke(false) + } + + + fun pause() { + mAudioTrack?.pause() + enterForeground?.invoke(false) + } + + fun play() { + mAudioTrack?.play() + enterForeground?.invoke(true) + } + +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt new file mode 100644 index 000000000..fcff96851 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt @@ -0,0 +1,79 @@ +package com.yunqiinnovation.qq_music.pcm + +import android.content.* +import android.os.Bundle +import android.os.IBinder +import androidx.appcompat.app.AppCompatActivity +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import com.yunqiinnovation.qq_music.* +import com.yunqiinnovation.qq_music.R + + +class PcmExampleActivityNew : AppCompatActivity() { + companion object { + const val TAG = "PcmExampleActivity" + } + + private lateinit var informationTextView: TextView + private var playService: IPlayService? = null + + private val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + playService = IPlayService.Stub.asInterface(service) + playService?.setPrintMessageCallback(object :IPrint.Stub(){ + override fun print(msg: String?) { + runOnUiThread { + informationTextView.text = msg + } + } + }) + } + + override fun onServiceDisconnected(name: ComponentName?) { + playService?.setPrintMessageCallback(null) + playService = null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_pcm_example) + + informationTextView = findViewById(R.id.tv_information) + + val intent = Intent(this, PlayerService::class.java) + startService(intent) + bindService(intent, conn, BIND_AUTO_CREATE) + + findViewById