diff --git a/.cursorrules b/.cursorrules index 99d6aa58c..a81ae52e0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,10 +1,73 @@ -1. 不要主动运行程序, 如flutter run -2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 -3. 不要使用print, 而是使用logger -4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget -5. 不要增加重试逻辑,出现失败直接返回错误 -6. 尽可能避免使用try catch -7. 尽可能避免打印日志,仅在出错时记录日志 +1. 请严格遵循 Effective Dart 的风格指南来编写代码 +2. 不要主动运行程序, 如flutter run +3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等 +4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志 +5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget +6. 不要增加重试逻辑,出现失败直接返回错误 +7. 尽可能避免使用try catch 8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 9. 请尽可能使用中文回复问题 -10. 重要:不要过度设计!! \ No newline at end of file +10. 重要:不要过度设计!! +11. 本项目使用 Kotlin DSL 进行开发 + +# 项目架构概述 +DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 + +## 总体架构 +- 采用GetX状态管理框架 +- 遵循模块化设计,各功能模块相对独立 +- 使用依赖注入实现组件解耦 +- 多语言支持,使用GetX国际化方案 + +## 主要目录结构 +- lib/core: 核心组件、工具和通用功能 +- lib/data: 数据模型、服务和提供者 +- lib/modules: 功能模块,按业务划分 +- lib/routes: 路由定义和管理 +- local_plugins: 本地插件,提供原生功能支持 + +# 核心模块说明 +## 语音处理 +- 火山语音服务: 提供语音识别和合成核心能力 +- Azure语音服务: 提供备选的语音识别和合成能力 +- 离线TTS支持: 在线语音合成失败时的备选方案 + +## 聊天功能 +- 基于OpenAI的对话功能 +- 支持语音输入、文字输入和语音输出 +- 支持多种角色和语音类型 + +## 用户系统 +- 支持多种登录方式:Firebase、Google、Facebook +- 用户资料管理和设置 +- 主题切换和语言设置 + +## 蓝牙配对 +- 支持蓝牙设备发现和连接 +- 提供简化的配对流程 +- 设备管理和状态监控 + +## 音频处理 +- 音频录制和播放 +- 支持波形显示 +- 音量控制和音频质量设置 + +# 技术栈 +- Flutter: UI框架 +- GetX: 状态管理和依赖注入 +- Firebase: 用户认证和后端服务 +- WebSocket: 实时通信 +- SQLite: 本地数据存储 +- 原生插件: 语音识别、合成和蓝牙功能 + +# 插件说明 +- jieli: 杰理蓝牙设备连接和控制插件 +- volcano_speech: 火山语音服务插件,提供语音识别和合成 +- azure_speech: 微软Azure语音服务插件 +- open_ai_service: OpenAI服务插件,提供AI对话能力 +- speech_to_text: 通用语音识别插件 + +# 环境配置 +- 环境变量通过.env文件管理 +- 支持不同环境的配置切换 +- 敏感信息不应直接硬编码在代码中 \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..a344e9c9a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + + "amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=e5fdc9605eabdeb5626f18f5721f343d" + }, + "web-search": { + "url": "http://mcp.ideapsound.com:8000/sse" + } + + } + } \ No newline at end of file diff --git a/amap_flutter/.gitignore b/amap_flutter/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/amap_flutter/.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/amap_flutter/.metadata b/amap_flutter/.metadata new file mode 100644 index 000000000..a5700b901 --- /dev/null +++ b/amap_flutter/.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: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: android + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: ios + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + + # 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/amap_flutter/CHANGELOG.md b/amap_flutter/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/amap_flutter/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/amap_flutter/LICENSE b/amap_flutter/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/amap_flutter/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/amap_flutter/README.md b/amap_flutter/README.md new file mode 100644 index 000000000..8ad963d6d --- /dev/null +++ b/amap_flutter/README.md @@ -0,0 +1,15 @@ +# amap_flutter + +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/amap_flutter/analysis_options.yaml b/amap_flutter/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/amap_flutter/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/amap_flutter/android/.gitignore b/amap_flutter/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/amap_flutter/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/amap_flutter/android/build.gradle b/amap_flutter/android/build.gradle new file mode 100644 index 000000000..393dff90b --- /dev/null +++ b/amap_flutter/android/build.gradle @@ -0,0 +1,54 @@ +group = "com.example.amap_flutter" +version = "1.0" + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.0") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" + +android { + namespace = "com.example.amap_flutter" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + implementation 'com.amap.api:3dmap-location-search:latest.integration' + + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/amap_flutter/android/settings.gradle b/amap_flutter/android/settings.gradle new file mode 100644 index 000000000..5f1ba0ee8 --- /dev/null +++ b/amap_flutter/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'amap_flutter' diff --git a/amap_flutter/android/src/main/AndroidManifest.xml b/amap_flutter/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3d69995fd --- /dev/null +++ b/amap_flutter/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java b/amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java new file mode 100644 index 000000000..ba72d7ac6 --- /dev/null +++ b/amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java @@ -0,0 +1,168 @@ +package com.example.amap_flutter; + +import android.content.Context; +import androidx.annotation.NonNull; +import android.util.Log; + +import com.amap.api.location.AMapLocation; +import com.amap.api.location.AMapLocationClient; +import com.amap.api.location.AMapLocationClientOption; +import com.amap.api.location.AMapLocationListener; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.EventChannel; +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 java.util.HashMap; +import java.util.Map; + +/** AmapFlutterPlugin */ +public class AmapFlutterPlugin implements FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private MethodChannel methodChannel; + private EventChannel eventChannel; + private Context applicationContext; + private AMapLocationClient mLocationClient; + private EventChannel.EventSink eventSink; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + applicationContext = flutterPluginBinding.getApplicationContext(); + methodChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter"); + methodChannel.setMethodCallHandler(this); + + // 初始化EventChannel + eventChannel = new EventChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter/events"); + eventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + eventSink = events; + } + + @Override + public void onCancel(Object arguments) { + eventSink = null; + } + }); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + switch (call.method) { + case "setApiKey": + setApiKey(call, result); + break; + case "initLocation": + initLocation(result); + break; + case "startLocation": + startLocation(result); + break; + case "stopLocation": + stopLocation(result); + break; + case "dispose": + dispose(result); + break; + default: + result.notImplemented(); + } + } + + private void setApiKey(MethodCall call, Result result) { + try { + String apiKey = call.argument("androidKey"); + AMapLocationClient.updatePrivacyShow(applicationContext, true, true); + AMapLocationClient.updatePrivacyAgree(applicationContext, true); + result.success(null); + } catch (Exception e) { + result.error("INIT_ERROR", "初始化失败", e.getMessage()); + } + } + + private void initLocation(Result result) { + try { + mLocationClient = new AMapLocationClient(applicationContext); + + // 配置定位参数 + AMapLocationClientOption option = new AMapLocationClientOption(); + option.setOnceLocation(true); + option.setNeedAddress(true); + mLocationClient.setLocationOption(option); + + mLocationClient.setLocationListener(mLocationListener); + result.success(null); + } catch (Exception e) { + result.error("INIT_ERROR", "初始化失败", e.getMessage()); + } + } + + private void startLocation(Result result) { + mLocationClient.startLocation(); + result.success(null); + } + + private void stopLocation(Result result) { + destroyLocationClient(); + result.success(null); + } + + private void dispose(Result result) { + try { + destroyLocationClient(); + result.success(null); + } catch (Exception e) { + result.error("DISPOSE_ERROR", "资源释放失败", e.getMessage()); + } + } + + private void destroyLocationClient() { + if (mLocationClient != null) { + try { + // 停止定位 + mLocationClient.stopLocation(); + // 销毁定位客户端 + mLocationClient.onDestroy(); + mLocationClient = null; + + // 关闭事件通道 + if (eventSink != null) { + eventSink.endOfStream(); + eventSink = null; + } + } catch (Exception e) { + Log.e("AmapFlutter", "销毁定位客户端失败", e); + } + } + } + + AMapLocationListener mLocationListener = new AMapLocationListener() { + @Override + public void onLocationChanged(AMapLocation amapLocation) { + if (eventSink == null) return; + + if (amapLocation != null) { + if (amapLocation.getErrorCode() == 0) { + Map locationData = new HashMap<>(); + locationData.put("latitude", amapLocation.getLatitude()); + locationData.put("longitude", amapLocation.getLongitude()); + locationData.put("address", amapLocation.getAddress()); + eventSink.success(locationData); + } + } + } + }; + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + methodChannel.setMethodCallHandler(null); + eventChannel.setStreamHandler(null); + destroyLocationClient(); + } +} diff --git a/amap_flutter/ios/.gitignore b/amap_flutter/ios/.gitignore new file mode 100644 index 000000000..034771fc9 --- /dev/null +++ b/amap_flutter/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/amap_flutter/ios/Assets/.gitkeep b/amap_flutter/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/amap_flutter/ios/Classes/AmapFlutterPlugin.swift b/amap_flutter/ios/Classes/AmapFlutterPlugin.swift new file mode 100644 index 000000000..b88657f78 --- /dev/null +++ b/amap_flutter/ios/Classes/AmapFlutterPlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class AmapFlutterPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "amap_flutter", binaryMessenger: registrar.messenger()) + let instance = AmapFlutterPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/amap_flutter/ios/Resources/PrivacyInfo.xcprivacy b/amap_flutter/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/amap_flutter/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/amap_flutter/ios/amap_flutter.podspec b/amap_flutter/ios/amap_flutter.podspec new file mode 100644 index 000000000..f9ef9f715 --- /dev/null +++ b/amap_flutter/ios/amap_flutter.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint amap_flutter.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'amap_flutter' + s.version = '0.0.1' + s.summary = 'A new Flutter project.' + s.description = <<-DESC +A new Flutter project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'amap_flutter_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/amap_flutter/lib/amap_flutter.dart b/amap_flutter/lib/amap_flutter.dart new file mode 100644 index 000000000..6e4c0645c --- /dev/null +++ b/amap_flutter/lib/amap_flutter.dart @@ -0,0 +1,36 @@ +import 'amap_flutter_platform_interface.dart'; + +class AmapFlutter { + // 初始化SDK + Future initSdk({String? iosKey, String? androidKey}) async { + await AmapFlutterPlatform.instance.setApiKey( + iosKey: iosKey, + androidKey: androidKey, + ); + } + + // 定位初始化 + Future initLocation() async { + return AmapFlutterPlatform.instance.initLocation(); + } + + // 开始定位 + Future startLocation() async { + return AmapFlutterPlatform.instance.startLocation(); + } + + // 监听定位返回位置 + Stream locationStream() { + return AmapFlutterPlatform.instance.locationStream(); + } + + // 停止定位 + Future stopLocation() async { + await AmapFlutterPlatform.instance.stopLocation(); + } + + // 销毁 + Future dispose() async { + await AmapFlutterPlatform.instance.dispose(); + } +} diff --git a/amap_flutter/lib/amap_flutter_method_channel.dart b/amap_flutter/lib/amap_flutter_method_channel.dart new file mode 100644 index 000000000..470a9a322 --- /dev/null +++ b/amap_flutter/lib/amap_flutter_method_channel.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'amap_flutter_platform_interface.dart'; + +/// An implementation of [AmapFlutterPlatform] that uses method channels. +class MethodChannelAmapFlutter extends AmapFlutterPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('amap_flutter'); + final eventChannel = const EventChannel('amap_flutter/events'); + + @override + Future setApiKey({String? iosKey, String? androidKey}) async { + await methodChannel.invokeMethod('setApiKey', { + "iosKey": iosKey, + "androidKey": androidKey, + }); + } + + @override + Future initLocation() async { + return await methodChannel.invokeMethod('initLocation'); + } + + @override + Future startLocation() async { + return await methodChannel.invokeMethod('startLocation'); + } + + @override + Stream locationStream() { + return eventChannel.receiveBroadcastStream(); + } + + @override + Future stopLocation() async { + await methodChannel.invokeMethod('stopLocation'); + } + + @override + Future dispose() async { + await methodChannel.invokeMethod('dispose'); + } +} diff --git a/amap_flutter/lib/amap_flutter_platform_interface.dart b/amap_flutter/lib/amap_flutter_platform_interface.dart new file mode 100644 index 000000000..dd1c168c1 --- /dev/null +++ b/amap_flutter/lib/amap_flutter_platform_interface.dart @@ -0,0 +1,49 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'amap_flutter_method_channel.dart'; + +abstract class AmapFlutterPlatform extends PlatformInterface { + /// Constructs a AmapFlutterPlatform. + AmapFlutterPlatform() : super(token: _token); + + static final Object _token = Object(); + + static AmapFlutterPlatform _instance = MethodChannelAmapFlutter(); + + /// The default instance of [AmapFlutterPlatform] to use. + /// + /// Defaults to [MethodChannelAmapFlutter]. + static AmapFlutterPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [AmapFlutterPlatform] when + /// they register themselves. + static set instance(AmapFlutterPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future setApiKey({String? iosKey, String? androidKey}) { + throw UnimplementedError('init() has not been implemented.'); + } + + Future initLocation() { + throw UnimplementedError('initLocation() has not been implemented.'); + } + + Future startLocation() { + throw UnimplementedError('startLocation() has not been implemented.'); + } + + Stream locationStream() { + throw UnimplementedError('locationStream() has not been implemented.'); + } + + Future stopLocation() { + throw UnimplementedError('stopLocation() has not been implemented.'); + } + + Future dispose() { + throw UnimplementedError('dispose() has not been implemented.'); + } +} diff --git a/amap_flutter/pubspec.yaml b/amap_flutter/pubspec.yaml new file mode 100644 index 000000000..b67a2bea6 --- /dev/null +++ b/amap_flutter/pubspec.yaml @@ -0,0 +1,72 @@ +name: amap_flutter +description: "A new Flutter project." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.7.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.example.amap_flutter + pluginClass: AmapFlutterPlugin + ios: + pluginClass: AmapFlutterPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 542cf24d8..883dea602 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,9 @@ import java.io.FileInputStream repositories { google() mavenCentral() + flatDir { + dirs(project(":spotify_sdk").projectDir.resolve("libs")) + } } plugins { @@ -34,7 +37,32 @@ android { jvmTarget = JavaVersion.VERSION_11.toString() } + // 添加lint选项 + lintOptions { + isCheckReleaseBuilds = false + } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } + defaultConfig { + manifestPlaceholders.putAll( + mapOf( + "redirectSchemeName" to "spotify-sdk", + "redirectHostName" to "auth" + ) + ) // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.yunqiinnovation.deepsound" // You can update the following values to match your application needs. @@ -82,14 +110,22 @@ android { dependencies { + implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") + + + // 添加本地插件模块依赖 + implementation(project(":azure_speech")) + implementation(project(":open_ai_service")) + implementation(project(":volcano_speech")) + implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":spotify_sdk")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") // 添加核心库反糖化 coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") - // Microsoft 语音识别SDK - implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.42.0") // 添加androidx.media依赖 implementation("androidx.media:media:1.6.0") @@ -100,11 +136,6 @@ dependencies { // 添加 AndroidX Security 加密 SharedPreferences 依赖 implementation("androidx.security:security-crypto:1.1.0-alpha06") - // // 添加火山语音合成SDK依赖 - // implementation("com.bytedance.speechengine:speechengine_tts_tob:5.4.8") - - // 添加火山语音识别SDK依赖 - // implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") } flutter { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f120cc1f7..5b8339d7c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,9 +27,30 @@ + + + + + + + + + + + + + + + + + + + + - + @@ -84,12 +105,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt new file mode 100644 index 000000000..9e4711531 --- /dev/null +++ b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt @@ -0,0 +1,236 @@ +package com.yunqiinnovation.chat_storage + +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +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 org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +// 定义数据库帮助类 +private class ChatDatabaseHelper(context: Context) : + SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + + companion object { + private const val DATABASE_NAME = "chat_messages.db" + private const val DATABASE_VERSION = 1 + + // 定义消息表 + private const val TABLE_MESSAGES = "messages" + private const val COLUMN_ID = "id" + private const val COLUMN_SESSION_ID = "session_id" + private const val COLUMN_MESSAGE = "message" + private const val COLUMN_SENDER = "sender" + private const val COLUMN_TIMESTAMP = "timestamp" + private const val COLUMN_METADATA = "metadata" + } + + override fun onCreate(db: SQLiteDatabase) { + // 创建消息表 + val createMessagesTable = """ + CREATE TABLE $TABLE_MESSAGES ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_SESSION_ID TEXT NOT NULL, + $COLUMN_MESSAGE TEXT NOT NULL, + $COLUMN_SENDER TEXT NOT NULL, + $COLUMN_TIMESTAMP TEXT NOT NULL, + $COLUMN_METADATA TEXT + ) + """.trimIndent() + + db.execSQL(createMessagesTable) + + // 创建索引以加快查询速度 + db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)") + db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // 处理数据库升级逻辑 + if (oldVersion < newVersion) { + // 简单的升级策略 - 删除旧表并创建新表 + // 在实际应用中,应该实现数据迁移而非删除 + db.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES") + onCreate(db) + } + } +} + +/** ChatStorageSqlitePlugin */ +class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { + private lateinit var channel : MethodChannel + private lateinit var context: Context + private lateinit var dbHelper: ChatDatabaseHelper + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage") + channel.setMethodCallHandler(this) + dbHelper = ChatDatabaseHelper(context) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "saveMessage" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val message = call.argument("message") ?: "" + val sender = call.argument("sender") ?: "" + val metadata = call.argument("metadata") + + val db = dbHelper.writableDatabase + + // 获取当前时间作为时间戳 + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + + // 执行插入操作 + val values = android.content.ContentValues().apply { + put("session_id", sessionId) + put("message", message) + put("sender", sender) + put("timestamp", timestamp) + put("metadata", metadata) + } + + val id = db.insert("messages", null, values) + if (id != -1L) { + result.success(mapOf("success" to true, "id" to id)) + } else { + result.error("DB_ERROR", "插入消息失败", null) + } + } catch (e: Exception) { + result.error("EXCEPTION", "保存消息时发生错误: ${e.message}", null) + } + } + "getMessages" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val page = call.argument("page") ?: 1 + val pageSize = call.argument("pageSize") ?: 20 + + val db = dbHelper.readableDatabase + val offset = (page - 1) * pageSize + + // 构建查询语句 + val query = """ + SELECT * FROM messages + WHERE session_id = ? + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + """.trimIndent() + + val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString()) + val cursor = db.rawQuery(query, selectionArgs) + + val messages = cursorToJsonArray(cursor) + + // 获取总记录数 + val countQuery = "SELECT COUNT(*) FROM messages WHERE session_id = ?" + val countCursor = db.rawQuery(countQuery, arrayOf(sessionId)) + var totalCount = 0 + if (countCursor.moveToFirst()) { + totalCount = countCursor.getInt(0) + } + countCursor.close() + + // 计算总页数 + val totalPages = if (totalCount % pageSize == 0) { + totalCount / pageSize + } else { + (totalCount / pageSize) + 1 + } + + val response = JSONObject().apply { + put("messages", messages) + put("page", page) + put("pageSize", pageSize) + put("totalCount", totalCount) + put("totalPages", totalPages) + } + + result.success(response.toString()) + } catch (e: Exception) { + result.error("EXCEPTION", "获取消息时发生错误: ${e.message}", null) + } + } + "deleteMessages" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val messageIds = call.argument>("messageIds") + + val db = dbHelper.writableDatabase + + if (messageIds != null && messageIds.isNotEmpty()) { + // 删除指定ID的消息 + val idsStr = messageIds.joinToString(",") + val deleteQuery = "DELETE FROM messages WHERE id IN ($idsStr)" + db.execSQL(deleteQuery) + result.success(mapOf("success" to true, "count" to messageIds.size)) + } else if (sessionId.isNotEmpty()) { + // 删除会话中的所有消息 + val deleteQuery = "DELETE FROM messages WHERE session_id = ?" + db.execSQL(deleteQuery, arrayOf(sessionId)) + result.success(mapOf("success" to true)) + } else { + result.error("INVALID_ARGS", "必须提供sessionId或messageIds", null) + } + } catch (e: Exception) { + result.error("EXCEPTION", "删除消息时发生错误: ${e.message}", null) + } + } + "clearDatabase" -> { + try { + val db = dbHelper.writableDatabase + db.execSQL("DELETE FROM messages") + result.success(mapOf("success" to true)) + } catch (e: Exception) { + result.error("EXCEPTION", "清空数据库时发生错误: ${e.message}", null) + } + } + else -> { + result.notImplemented() + } + } + } + + private fun cursorToJsonArray(cursor: Cursor): JSONArray { + val jsonArray = JSONArray() + + val idIndex = cursor.getColumnIndex("id") + val sessionIdIndex = cursor.getColumnIndex("session_id") + val messageIndex = cursor.getColumnIndex("message") + val senderIndex = cursor.getColumnIndex("sender") + val timestampIndex = cursor.getColumnIndex("timestamp") + val metadataIndex = cursor.getColumnIndex("metadata") + + while (cursor.moveToNext()) { + val jsonObject = JSONObject().apply { + put("id", cursor.getInt(idIndex)) + put("sessionId", cursor.getString(sessionIdIndex)) + put("message", cursor.getString(messageIndex)) + put("sender", cursor.getString(senderIndex)) + put("timestamp", cursor.getString(timestampIndex)) + + if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) { + put("metadata", cursor.getString(metadataIndex)) + } + } + jsonArray.put(jsonObject) + } + + cursor.close() + return jsonArray + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/.gitkeep b/local_plugins/chat_storage/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/chat_storage/ios/chat_storage.podspec b/local_plugins/chat_storage/ios/chat_storage.podspec new file mode 100644 index 000000000..b7a2c39b8 --- /dev/null +++ b/local_plugins/chat_storage/ios/chat_storage.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint chat_storage.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'chat_storage' + s.version = '0.0.1' + s.summary = 'Chat Storage plugin' + s.description = <<-DESC +A Flutter plugin for chat storage services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/chat_storage/lib/chat_storage.dart b/local_plugins/chat_storage/lib/chat_storage.dart new file mode 100644 index 000000000..2119dd84b --- /dev/null +++ b/local_plugins/chat_storage/lib/chat_storage.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// 聊天记录数据模型 +class ChatMessage { + final int? id; + final String sessionId; + final String message; + final String sender; + final String timestamp; + final String? metadata; + + ChatMessage({ + this.id, + required this.sessionId, + required this.message, + required this.sender, + required this.timestamp, + this.metadata, + }); + + Map toMap() { + return { + 'id': id, + 'sessionId': sessionId, + 'message': message, + 'sender': sender, + 'timestamp': timestamp, + 'metadata': metadata, + }; + } + + factory ChatMessage.fromMap(Map map) { + return ChatMessage( + id: map['id'], + sessionId: map['sessionId'], + message: map['message'], + sender: map['sender'], + timestamp: map['timestamp'], + metadata: map['metadata'], + ); + } +} + +/// 分页结果数据模型 +class PagedResult { + final List messages; + final int page; + final int pageSize; + final int totalCount; + final int totalPages; + + PagedResult({ + required this.messages, + required this.page, + required this.pageSize, + required this.totalCount, + required this.totalPages, + }); +} + +/// SQLite聊天记录存储插件 +class ChatStorage { + static const MethodChannel _channel = MethodChannel('chat_storage'); + + /// 保存一条聊天记录 + static Future> saveMessage({ + required String sessionId, + required String message, + required String sender, + String? metadata, + }) async { + try { + final result = await _channel.invokeMethod('saveMessage', { + 'sessionId': sessionId, + 'message': message, + 'sender': sender, + }); + return Map.from(result); + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + } + + /// 获取指定会话的聊天记录(支持分页) + static Future getMessages({ + required String sessionId, + int page = 1, + int pageSize = 20, + }) async { + try { + final String result = await _channel.invokeMethod('getMessages', { + 'sessionId': sessionId, + 'page': page, + 'pageSize': pageSize, + }); + + final Map data = jsonDecode(result); + final List messagesList = data['messages']; + + List messages = messagesList.map((item) { + return ChatMessage.fromMap(Map.from(item)); + }).toList(); + + return PagedResult( + messages: messages, + page: data['page'], + pageSize: data['pageSize'], + totalCount: data['totalCount'], + totalPages: data['totalPages'], + ); + } catch (e) { + return PagedResult( + messages: [], + page: page, + pageSize: pageSize, + totalCount: 0, + totalPages: 0, + ); + } + } + + /// 删除聊天记录,可按会话ID或消息ID列表删除 + static Future> deleteMessages({ + String? sessionId, + List? messageIds, + }) async { + try { + if (sessionId == null && (messageIds == null || messageIds.isEmpty)) { + return {'success': false, 'error': '必须提供sessionId或messageIds'}; + } + + final result = await _channel.invokeMethod('deleteMessages', { + 'sessionId': sessionId, + 'messageIds': messageIds, + }); + return Map.from(result); + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + } + + /// 清空整个数据库 + static Future> clearDatabase() async { + try { + final result = await _channel.invokeMethod('clearDatabase'); + return Map.from(result); + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/pubspec.yaml b/local_plugins/chat_storage/pubspec.yaml new file mode 100644 index 000000000..0a68a4a25 --- /dev/null +++ b/local_plugins/chat_storage/pubspec.yaml @@ -0,0 +1,30 @@ +name: chat_storage +description: A Flutter plugin for storing chat messages using SQLite. +version: 0.0.1 +# homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + sqflite: ^2.3.0 + path: ^1.8.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + plugin: + platforms: + android: + package: com.yunqiinnovation.chat_storage + pluginClass: ChatStoragePlugin + ios: + pluginClass: ChatStoragePlugin \ No newline at end of file diff --git a/local_plugins/jieli/README.md b/local_plugins/jieli/README.md new file mode 100644 index 000000000..d926109c8 --- /dev/null +++ b/local_plugins/jieli/README.md @@ -0,0 +1,144 @@ +# Jieli 插件 + +一个用于连接杰理蓝牙耳机的Flutter插件,支持Android 12+的BLE通信。 + +## 功能 + +- 扫描支持特定BLE服务的耳机设备 +- 连接设备并发现服务 +- 接收耳机的唤醒通知 +- 向耳机发送控制命令启动录音 +- 接收音频数据流 + +## 技术规范 + +此插件实现了以下BLE特性: + +- **服务UUID**: 0000A100-0000-1000-8000-00805F9B34FB +- **音频数据特征UUID**: 0000A101-0000-1000-8000-00805F9B34FB +- **控制特征UUID**: 0000A102-0000-1000-8000-00805F9B34FB + +## 使用方法 + +### 初始化插件 + +```dart +final jieli = Jieli(); +``` + +### 检查蓝牙状态 + +```dart +bool isEnabled = await jieli.isBluetoothEnabled(); +if (!isEnabled) { + await jieli.requestBluetoothEnable(); +} +``` + +### 扫描设备 + +```dart +// 监听扫描结果 +jieli.scanResults.listen((devices) { + // 处理扫描到的设备列表 + for (var device in devices) { + print('发现设备: ${device.name} (${device.id}), RSSI: ${device.rssi}'); + } +}); + +// 开始扫描,10秒后自动停止 +await jieli.startScan(scanTimeout: 10000); + +// 手动停止扫描 +await jieli.stopScan(); +``` + +### 连接设备 + +```dart +// 监听连接状态 +jieli.connectionState.listen((state) { + switch (state) { + case ConnectionState.connected: + print('设备已连接'); + break; + case ConnectionState.disconnected: + print('设备已断开'); + break; + case ConnectionState.connecting: + print('正在连接...'); + break; + case ConnectionState.disconnecting: + print('正在断开...'); + break; + case ConnectionState.error: + print('连接错误'); + break; + } +}); + +// 连接到设备 +await jieli.connect(deviceId); + +// 断开连接 +await jieli.disconnect(); +``` + +### 接收唤醒信号 + +```dart +// 监听唤醒信号 +jieli.wakeupStream.listen((wakeup) { + print('收到唤醒信号'); + // 通常在收到唤醒后自动发送开始录音命令 +}); +``` + +### 发送录音命令 + +```dart +// 发送开始录音命令 +await jieli.sendStartRecordingCommand(); + +// 发送停止录音命令 +await jieli.sendStopRecordingCommand(); +``` + +### 接收音频数据 + +```dart +// 监听音频数据流 +jieli.audioDataStream.listen((data) { + print('收到音频数据: ${data.length} 字节'); + // 处理音频数据 +}); +``` + +### 资源清理 + +```dart +// 在不再需要插件时释放资源 +await jieli.dispose(); +``` + +## 权限 + +使用此插件需要以下Android权限: + +- Android 12+ (API 31+): `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` +- Android 11及以下: `BLUETOOTH`, `BLUETOOTH_ADMIN` + +插件会在需要时自动请求权限,但建议在应用清单文件中声明这些权限: + +```xml + + + + + + + +``` \ No newline at end of file diff --git a/local_plugins/jieli/android/build.gradle.kts b/local_plugins/jieli/android/build.gradle.kts new file mode 100644 index 000000000..70b01c20e --- /dev/null +++ b/local_plugins/jieli/android/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + // Android Library 插件 + id("com.android.library") + // Kotlin Android 插件 + id("org.jetbrains.kotlin.android") + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +android { + // 命名空间,对应你插件的包名(需与代码内包名保持一致) + namespace = "com.yunqiinnovation.jieli" + + // 目标 SDK 版本 + compileSdk = 33 + + defaultConfig { + // 最低 SDK 版本 + minSdk = 21 + targetSdk = 33 + } + + // Java 语言级别兼容配置 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // Kotlin 语言级别 + kotlinOptions { + jvmTarget = "11" + } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10") + implementation("androidx.core:core-ktx:1.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0") + implementation("androidx.lifecycle:lifecycle-service:2.4.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") +} \ No newline at end of file diff --git a/local_plugins/jieli/android/settings.gradle.kts b/local_plugins/jieli/android/settings.gradle.kts new file mode 100644 index 000000000..c0323d3bc --- /dev/null +++ b/local_plugins/jieli/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "jieli" \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/AndroidManifest.xml b/local_plugins/jieli/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3cf7a945e --- /dev/null +++ b/local_plugins/jieli/android/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt new file mode 100644 index 000000000..4534aade2 --- /dev/null +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt @@ -0,0 +1,549 @@ +package com.yunqiinnovation.jieli + +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import android.util.Log +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.* +import java.util.* + +/** + * BLE服务类,提供核心蓝牙功能 + */ +class BleService(private val context: Context) { + companion object { + private const val TAG = "JieliBleService" + + // 服务和特征UUID - 根据协议规范重新定义 + private const val AUDIO_SERVICE_UUID = "0000faa0-0000-1000-8000-00805f9b34fb" // 主服务UUID + private const val NOTIFY_CHAR_UUID = "0000faa2-0000-1000-8000-00805f9b34fb" // 用于接收数据的特征(notify) + private const val WRITE_CHAR_UUID = "0000faa1-0000-1000-8000-00805f9b34fb" // 用于发送命令的特征(write without response) + private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" // 标准的客户端特征配置描述符 + // 连接状态常量 + const val STATE_DISCONNECTED = 0 + const val STATE_CONNECTING = 1 + const val STATE_CONNECTED = 2 + const val STATE_DISCONNECTING = 3 + const val STATE_ERROR = 4 + + // 扫描结果更新间隔(毫秒) + private const val SCAN_RESULT_UPDATE_INTERVAL = 500L + + // 命令类型常量 + const val COMMAND_VOLUME = 1 + } + + /** + * 回调接口 + */ + interface Callback { + fun onScanResult(devices: List>) + fun onConnectionStateChanged(state: Int) + fun onAudioDataReceived(data: ByteArray) + fun onWakeupSignalReceived() + fun onCommandResult(commandType: Int, success: Boolean) + } + + // 蓝牙管理器和适配器 + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter + private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + + // GATT连接和特征 + private var bluetoothGatt: BluetoothGatt? = null + private var notifyChar: BluetoothGattCharacteristic? = null + private var writeChar: BluetoothGattCharacteristic? = null + + // 扫描相关 + private val scanHandler = Handler(Looper.getMainLooper()) + private val scanResults = ArrayList() + private var isScanning = false + private var isPendingScanResultUpdate = false + private val scanResultUpdateRunnable = Runnable { + notifyScanResults() + isPendingScanResultUpdate = false + } + + // 连接状态LiveData + val connectionState = MutableLiveData(STATE_DISCONNECTED) + + // 统一回调接口 + var callback: Callback? = null + + /** + * 开始扫描BLE设备 + */ + fun startScan(scanTimeout: Long = 5000): Boolean { + if (bluetoothLeScanner == null || isScanning) { + return false + } + + Log.i(TAG, "开始扫描BLE设备...") + scanResults.clear() + + val serviceUuid = UUID.fromString(AUDIO_SERVICE_UUID) + val scanFilter = ScanFilter.Builder() + // .setServiceUuid(ParcelUuid(serviceUuid)) + .build() + val scanFilters = listOf(scanFilter) + + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback) + isScanning = true + + // 设置超时停止扫描 + scanHandler.postDelayed({ + Log.d(TAG, "定时器触发,准备停止扫描 [主线程:${Looper.myLooper() == Looper.getMainLooper()}]") + stopScan() + }, scanTimeout) + + return true + } + + /** + * 停止扫描 + */ + fun stopScan(): Boolean { + Log.i(TAG, "停止扫描BLE设备") + + if (bluetoothLeScanner == null || !isScanning) { + return false + } + + bluetoothLeScanner.stopScan(scanCallback) + scanHandler.removeCallbacksAndMessages(null) + isScanning = false + + // 最后一次发送扫描结果 + notifyScanResults() + + return true + } + + /** + * 扫描回调 + */ + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val deviceName = device.name ?: "未知设备" + val deviceAddress = device.address + val rssi = result.rssi + + // Log.d(TAG, "发现设备: $deviceName [$deviceAddress], RSSI: $rssi") + + // 避免重复添加 + val existingIndex = scanResults.indexOfFirst { it.device.address == deviceAddress } + if (existingIndex >= 0) { + scanResults[existingIndex] = result + } else { + scanResults.add(result) + } + + // 如果没有待处理的更新,安排一个 + if (!isPendingScanResultUpdate) { + isPendingScanResultUpdate = true + scanHandler.postDelayed(scanResultUpdateRunnable, SCAN_RESULT_UPDATE_INTERVAL) + } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "扫描失败,错误码: $errorCode") + isScanning = false + } + } + + /** + * 将扫描结果通知回调 + */ + private fun notifyScanResults() { + val devicesJson = scanResults.map { result -> + mapOf( + "id" to result.device.address, + "name" to (result.device.name ?: "未知设备"), + "rssi" to result.rssi + ) + } + + callback?.onScanResult(devicesJson) + } + + /** + * 连接到指定设备 + */ + fun connect(deviceAddress: String) { + if (bluetoothAdapter == null) { + Log.e(TAG, "蓝牙适配器不可用") + updateConnectionState(STATE_ERROR) + return + } + + // 先断开现有连接 + bluetoothGatt?.close() + bluetoothGatt = null + + try { + val device = bluetoothAdapter.getRemoteDevice(deviceAddress) + updateConnectionState(STATE_CONNECTING) + + // 连接GATT服务器 + Log.i(TAG, "正在连接设备: ${device.name ?: "未知设备"} [$deviceAddress]") + bluetoothGatt = device.connectGatt(context, false, gattCallback) + } catch (e: Exception) { + Log.e(TAG, "连接设备时发生错误: ${e.message}") + updateConnectionState(STATE_ERROR) + } + } + + /** + * 断开连接 + */ + fun disconnect() { + bluetoothGatt?.disconnect() + updateConnectionState(STATE_DISCONNECTING) + } + + /** + * 更新连接状态并通知回调 + */ + private fun updateConnectionState(state: Int) { + connectionState.postValue(state) + callback?.onConnectionStateChanged(state) + } + + /** + * GATT回调 + */ + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "设备已连接,开始发现服务...") + updateConnectionState(STATE_CONNECTED) + + // 开始搜索服务 + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.i(TAG, "设备已断开连接") + updateConnectionState(STATE_DISCONNECTED) + + // 清理资源 + gatt.close() + bluetoothGatt = null + notifyChar = null + writeChar = null + } else if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "连接失败,status=$status") + updateConnectionState(STATE_ERROR) + + gatt.close() + bluetoothGatt = null + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "服务发现成功") + + // 查找目标服务 + val audioService = gatt.getService(UUID.fromString(AUDIO_SERVICE_UUID)) + if (audioService != null) { + Log.i(TAG, "找到音频服务") + + // 获取特征 + notifyChar = audioService.getCharacteristic(UUID.fromString(NOTIFY_CHAR_UUID)) + writeChar = audioService.getCharacteristic(UUID.fromString(WRITE_CHAR_UUID)) + + if (notifyChar != null && writeChar != null) { + Log.i(TAG, "找到所需特征") + + // 设置特征写入类型为无响应模式 + writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + + // 注册特征通知 + setupNotifications(gatt) + } else { + Log.e(TAG, "未找到所需特征") + updateConnectionState(STATE_ERROR) + } + } else { + Log.e(TAG, "未找到音频服务") + updateConnectionState(STATE_ERROR) + } + } else { + Log.e(TAG, "服务发现失败,status=$status") + updateConnectionState(STATE_ERROR) + } + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + val characteristicUuid = characteristic.uuid.toString() + Log.d(TAG, "收到特征变化通知: $characteristicUuid") + + when (characteristicUuid) { + // NOTIFY_CHAR_UUID -> { + // // 收到音频数据 + // val audioData = characteristic.value + // Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") + + // callback?.onAudioDataReceived(audioData) + // } + NOTIFY_CHAR_UUID -> { + // 处理控制消息 + val value = characteristic.value + Log.i(TAG, "收到控制通知: ${value.contentToString()}") + + // 判断是否是协议格式响应 + if (value.isNotEmpty() && value[0].toInt() and 0xFF == 0xA5) { + // 处理设备返回的协议数据 + processResponseData(value) + } + // 假设0x01表示唤醒信号 + else if (value.isNotEmpty() && value[0].toInt() == 0x01) { + Log.i(TAG, "收到唤醒信号") + + callback?.onWakeupSignalReceived() + + // 收到唤醒后自动发送开始录音命令 + sendStartRecordingCommand() + } + } + } + } + + override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (characteristic.uuid.toString() == WRITE_CHAR_UUID) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") + } else { + Log.e(TAG, "命令发送失败,status=$status") + } + } + } + + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") + Log.i(TAG, "特征通知设置成功,设备就绪") + } else { + Log.e(TAG, "描述符写入失败,status=$status") + updateConnectionState(STATE_ERROR) + } + } + } + + /** + * 设置特征通知 + */ + private fun setupNotifications(gatt: BluetoothGatt) { + // 启用 notify 特征通知 + if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + gatt.setCharacteristicNotification(notifyChar, true) + + val descriptor = notifyChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) + descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + + if (descriptor != null) { + gatt.writeDescriptor(descriptor) + Log.i(TAG, "设置数据通知特征通知") + } + } else { + Log.e(TAG, "数据通知特征不支持通知") + } + } + + /** + * 处理设备返回的数据 + * 协议格式: + * 协议头(0xA5) + 字段类型(1byte) + 数据长度(1byte) + 数据域(N byte) + 校验和(1byte) + */ + private fun processResponseData(data: ByteArray) { + if (data.size < 4) { + Log.e(TAG, "返回数据格式错误,长度不足") + return + } + + // 判断协议头 + if (data[0].toInt() and 0xFF != 0xA5) { + Log.e(TAG, "返回数据协议头错误: ${data[0].toInt() and 0xFF}") + return + } + + val fieldType = data[1].toInt() and 0xFF + val dataLen = data[2].toInt() and 0xFF + + // 验证长度 + if (data.size != dataLen + 4) { // 协议头+字段类型+数据长度+数据域+校验和 + Log.e(TAG, "返回数据长度不匹配: 应为${dataLen + 4},实际为${data.size}") + return + } + + // 计算校验和 + var checksum = 0 + for (i in 0 until data.size - 1) { + checksum += data[i].toInt() and 0xFF + } + checksum = checksum and 0xFF + + val receivedChecksum = data[data.size - 1].toInt() and 0xFF + + if (checksum != receivedChecksum) { + Log.e(TAG, "校验和错误: 计算值=$checksum, 接收值=$receivedChecksum") + return + } + + // 解析字段类型 + when (fieldType) { + 0x01 -> { + // 音量调整命令的响应 + val result = data[3].toInt() and 0xFF + val success = result == 0 + Log.i(TAG, "音量调整命令响应: ${if (success) "成功" else "失败"}") + callback?.onCommandResult(COMMAND_VOLUME, success) + } + // 其他命令类型可在此处添加 + else -> { + Log.d(TAG, "未知命令类型: ${String.format("0x%02X", fieldType)}") + } + } + } + + /** + * 发送开始录音命令 + */ + fun sendStartRecordingCommand(): Boolean { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送开始录音命令,设备未连接") + return false + } + + try { + val commandData = byteArrayOf(0x01) + writeChar?.value = commandData + + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false + + if (success) { + Log.i(TAG, "开始录音命令已发送") + } else { + Log.e(TAG, "开始录音命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送开始录音命令时发生异常: ${e.message}") + return false + } + } + + /** + * 发送停止录音命令 + */ + fun sendStopRecordingCommand(): Boolean { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送停止录音命令,设备未连接") + return false + } + + try { + val commandData = byteArrayOf(0x02) + writeChar?.value = commandData + + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false + + if (success) { + Log.i(TAG, "停止录音命令已发送") + } else { + Log.e(TAG, "停止录音命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送停止录音命令时发生异常: ${e.message}") + return false + } + } + + /** + * 发送音量调整命令 + * 协议格式: + * 协议头(0xAA) + 字段类型(0x01) + 数据长度(0x01) + 音量值(0~16) + 校验和 + */ + fun adjustVolume(volume: Int): Boolean { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送音量调整命令,设备未连接") + return false + } + + // 限制音量范围为0-16 + val safeVolume = volume.coerceIn(0, 16) + + try { + // 构建命令数据 + val header: Byte = 0xAA.toByte() + val fieldType: Byte = 0x01 + val dataLen: Byte = 0x01 + val volumeData: Byte = safeVolume.toByte() + + // 计算校验和 + val checksum = ((header.toInt() and 0xFF) + + (fieldType.toInt() and 0xFF) + + (dataLen.toInt() and 0xFF) + + (volumeData.toInt() and 0xFF)) and 0xFF + + // 创建完整命令 + val commandData = byteArrayOf(header, fieldType, dataLen, volumeData, checksum.toByte()) + + // 写入特征 + writeChar?.value = commandData + + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false + + if (success) { + Log.i(TAG, "音量调整命令已发送: $safeVolume") + } else { + Log.e(TAG, "音量调整命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送音量调整命令时发生异常: ${e.message}") + return false + } + } + + /** + * 增加音量 + */ + fun increaseVolume(): Boolean { + // 假设当前音量保存在设备中,我们只需发送增加命令 + return adjustVolume(16) // 发送最大音量命令 + } + + /** + * 减小音量 + */ + fun decreaseVolume(): Boolean { + // 假设当前音量保存在设备中,我们只需发送减小命令 + return adjustVolume(0) // 发送最小音量命令 + } + + /** + * 清理资源 + */ + fun cleanup() { + stopScan() + scanHandler.removeCallbacks(scanResultUpdateRunnable) + bluetoothGatt?.close() + bluetoothGatt = null + notifyChar = null + writeChar = null + } +} \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt new file mode 100644 index 000000000..95a267e97 --- /dev/null +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt @@ -0,0 +1,292 @@ +package com.yunqiinnovation.jieli + +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.annotation.NonNull +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +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.EventChannel +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 kotlinx.coroutines.* + +/** JieliPlugin */ +class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler { + /// 方法通道 + private lateinit var methodChannel: MethodChannel + /// 统一事件通道 + private lateinit var eventChannel: EventChannel + + private lateinit var context: Context + private var activity: Activity? = null + + /// BLE服务实例 + private lateinit var bleService: BleService + + // 协程作用域 + private val pluginScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // 事件接收器 + private var eventSink: EventChannel.EventSink? = null + + // 事件类型常量 + companion object { + private const val EVENT_TYPE_AUDIO_DATA = "audio_data" + private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" + private const val EVENT_TYPE_SCAN_RESULT = "scan_result" + private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" + private const val EVENT_TYPE_COMMAND_RESULT = "command_result" + } + + // 蓝牙相关请求码 + private val REQUEST_ENABLE_BT = 1 + private val REQUEST_PERMISSION_BLE = 2 + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + val binaryMessenger = flutterPluginBinding.binaryMessenger + + // 设置方法通道 + methodChannel = MethodChannel(binaryMessenger, "com.yunqiinnovation.jieli") + methodChannel.setMethodCallHandler(this) + + // 设置统一事件通道 + eventChannel = EventChannel(binaryMessenger, "com.yunqiinnovation.jieli/events") + eventChannel.setStreamHandler(this) + + context = flutterPluginBinding.applicationContext + + // 初始化BLE服务 + bleService = BleService(context) + + // 设置回调 + setupBleCallbacks() + } + + /** + * 设置BLE服务回调 + */ + private fun setupBleCallbacks() { + // 设置统一回调接口 + bleService.callback = object : BleService.Callback { + override fun onScanResult(devices: List>) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_SCAN_RESULT, devices) + } + } + + override fun onConnectionStateChanged(state: Int) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_CONNECTION_STATE, state) + } + } + + override fun onAudioDataReceived(data: ByteArray) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_AUDIO_DATA, data) + } + } + + override fun onWakeupSignalReceived() { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) + } + } + + override fun onCommandResult(commandType: Int, success: Boolean) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_COMMAND_RESULT, mapOf( + "commandType" to commandType, + "success" to success + )) + } + } + } + } + + /** + * 发送事件到Flutter端 + */ + private fun sendEvent(eventType: String, data: Any?) { + eventSink?.success(mapOf( + "type" to eventType, + "data" to data + )) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "startScan" -> { + val scanTimeout = call.argument("scanTimeout") ?: 10000 + + if (!hasRequiredPermissions()) { + requestBlePermissions() + result.error("PERMISSION_DENIED", "需要蓝牙权限才能扫描设备", null) + return + } + + val success = bleService.startScan(scanTimeout.toLong()) + result.success(success) + } + "stopScan" -> { + val success = bleService.stopScan() + result.success(success) + } + "connect" -> { + val deviceId = call.argument("deviceId") + if (deviceId == null) { + result.error("INVALID_ARGUMENT", "设备ID不能为空", null) + return + } + + if (!hasRequiredPermissions()) { + requestBlePermissions() + result.error("PERMISSION_DENIED", "需要蓝牙权限才能连接设备", null) + return + } + + bleService.connect(deviceId) + result.success(true) + } + "disconnect" -> { + bleService.disconnect() + result.success(true) + } + "sendStartRecordingCommand" -> { + val success = bleService.sendStartRecordingCommand() + result.success(success) + } + "sendStopRecordingCommand" -> { + val success = bleService.sendStopRecordingCommand() + result.success(success) + } + "isBluetoothEnabled" -> { + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val bluetoothAdapter = bluetoothManager.adapter + result.success(bluetoothAdapter?.isEnabled == true) + } + "requestBluetoothEnable" -> { + if (activity == null) { + result.error("ACTIVITY_NOT_AVAILABLE", "Activity不可用,无法请求打开蓝牙", null) + return + } + + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val bluetoothAdapter = bluetoothManager.adapter + + if (bluetoothAdapter == null) { + result.error("BLUETOOTH_UNAVAILABLE", "设备不支持蓝牙", null) + return + } + + if (bluetoothAdapter.isEnabled) { + result.success(true) + return + } + + // 请求打开蓝牙 + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) + result.success(true) + } + "adjustVolume" -> { + val volume = call.argument("volume") ?: 0 + val success = bleService.adjustVolume(volume) + result.success(success) + } + "increaseVolume" -> { + val success = bleService.increaseVolume() + result.success(success) + } + "decreaseVolume" -> { + val success = bleService.decreaseVolume() + result.success(success) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + + bleService.cleanup() + pluginScope.cancel() + } + + 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 + } + + /// EventChannel.StreamHandler接口实现 + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + /// 检查是否拥有所需权限 + private fun hasRequiredPermissions(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + hasPermission(android.Manifest.permission.BLUETOOTH_SCAN) && + hasPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + } else { + hasPermission(android.Manifest.permission.BLUETOOTH) && + hasPermission(android.Manifest.permission.BLUETOOTH_ADMIN) + } + } + + /// 检查单个权限 + private fun hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + /// 请求BLE权限 + private fun requestBlePermissions() { + activity?.let { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + ActivityCompat.requestPermissions( + it, + arrayOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT + ), + REQUEST_PERMISSION_BLE + ) + } else { + ActivityCompat.requestPermissions( + it, + arrayOf( + android.Manifest.permission.BLUETOOTH, + android.Manifest.permission.BLUETOOTH_ADMIN + ), + REQUEST_PERMISSION_BLE + ) + } + } + } +} \ No newline at end of file diff --git a/local_plugins/jieli/lib/jieli.dart b/local_plugins/jieli/lib/jieli.dart new file mode 100644 index 000000000..fa65eca91 --- /dev/null +++ b/local_plugins/jieli/lib/jieli.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; + +/// 设备类,代表一个BLE设备 +class BleDevice { + final String id; + final String name; + final int rssi; + + BleDevice({required this.id, required this.name, required this.rssi}); + + factory BleDevice.fromJson(Map json) { + return BleDevice( + id: json['id'] as String, + name: json['name'] as String? ?? 'Unknown Device', + rssi: json['rssi'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'rssi': rssi, + }; + } + + @override + String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi)'; +} + +/// 连接状态 +enum ConnectionState { + disconnected, + connecting, + connected, + disconnecting, + error +} + +/// 事件类型 +class EventType { + static const String audioData = 'audio_data'; + static const String connectionState = 'connection_state'; + static const String scanResult = 'scan_result'; + static const String wakeupSignal = 'wakeup_signal'; + static const String commandResult = 'command_result'; +} + +/// 杰理BLE插件主类 +class Jieli { + static const MethodChannel _methodChannel = MethodChannel('com.yunqiinnovation.jieli'); + static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.jieli/events'); + + /// 扫描结果流 + static final StreamController> _scanController = + StreamController>.broadcast(); + Stream> get scanResults => _scanController.stream; + + /// 连接状态变化流 + static final StreamController _connectionController = + StreamController.broadcast(); + Stream get connectionState => _connectionController.stream; + + /// 音频数据流 + static final StreamController _audioController = + StreamController.broadcast(); + Stream get audioDataStream => _audioController.stream; + + /// 唤醒信号流 + static final StreamController _wakeupController = + StreamController.broadcast(); + Stream get wakeupStream => _wakeupController.stream; + + /// 命令结果流 + static final StreamController _commandResultController = + StreamController.broadcast(); + Stream get commandResultStream => _commandResultController.stream; + + /// 原始事件流 + Stream>? _eventStream; + + /// 事件流订阅 + StreamSubscription? _eventSubscription; + + /// 命令类型常量 + static const int commandTypeVolume = 1; + + /// 构造函数,初始化平台通道和事件监听 + Jieli() { + _setupEventListener(); + } + + /// 设置事件监听器 + void _setupEventListener() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen(_handleEvent); + } + + /// 处理来自原生平台的事件 + void _handleEvent(dynamic event) { + if (event is! Map) return; + + final eventData = Map.from(event); + final String eventType = eventData['type'] as String; + final dynamic data = eventData['data']; + + switch (eventType) { + case EventType.scanResult: + if (data is List) { + final deviceList = data + .map((item) => BleDevice.fromJson(Map.from(item))) + .toList(); + _scanController.add(deviceList); + } + break; + case EventType.connectionState: + if (data is int && data >= 0 && data < ConnectionState.values.length) { + _connectionController.add(ConnectionState.values[data]); + } + break; + case EventType.audioData: + if (data is Uint8List) { + _audioController.add(data); + } + break; + case EventType.wakeupSignal: + if (data is bool) { + _wakeupController.add(data); + } + break; + case EventType.commandResult: + if (data is Map) { + final commandType = data['commandType'] as int; + final success = data['success'] as bool; + _commandResultController.add(CommandResult( + commandType: commandType, + success: success, + )); + } + break; + } + } + + /// 开始扫描BLE设备 + Future startScan({int scanTimeout = 10000}) async { + final result = await _methodChannel.invokeMethod( + 'startScan', + {'scanTimeout': scanTimeout}, + ); + return result ?? false; + } + + /// 停止扫描BLE设备 + Future stopScan() async { + final result = await _methodChannel.invokeMethod('stopScan'); + return result ?? false; + } + + /// 连接到指定设备 + Future connect(String deviceId) async { + final result = await _methodChannel.invokeMethod( + 'connect', + {'deviceId': deviceId}, + ); + return result ?? false; + } + + /// 断开连接 + Future disconnect() async { + final result = await _methodChannel.invokeMethod('disconnect'); + return result ?? false; + } + + /// 发送开始录音命令 + Future sendStartRecordingCommand() async { + final result = await _methodChannel.invokeMethod('sendStartRecordingCommand'); + return result ?? false; + } + + /// 发送停止录音命令 + Future sendStopRecordingCommand() async { + final result = await _methodChannel.invokeMethod('sendStopRecordingCommand'); + return result ?? false; + } + + /// 关闭插件,释放资源 + Future dispose() async { + await stopScan(); + await disconnect(); + _eventSubscription?.cancel(); + _scanController.close(); + _connectionController.close(); + _audioController.close(); + _wakeupController.close(); + _commandResultController.close(); + } + + /// 检查蓝牙是否已启用 + Future isBluetoothEnabled() async { + final result = await _methodChannel.invokeMethod('isBluetoothEnabled'); + return result ?? false; + } + + /// 请求打开蓝牙 + Future requestBluetoothEnable() async { + final result = await _methodChannel.invokeMethod('requestBluetoothEnable'); + return result ?? false; + } + + /// 调整音量 + /// [volume] 音量值,范围0-16 + Future adjustVolume(int volume) async { + try { + final result = await _methodChannel.invokeMethod( + 'adjustVolume', + {'volume': volume}, + ); + return result ?? false; + } catch (e) { + print('调整音量失败: $e'); + return false; + } + } + + /// 增加音量 + Future increaseVolume() async { + try { + final result = await _methodChannel.invokeMethod('increaseVolume'); + return result ?? false; + } catch (e) { + print('增加音量失败: $e'); + return false; + } + } + + /// 减小音量 + Future decreaseVolume() async { + try { + final result = await _methodChannel.invokeMethod('decreaseVolume'); + return result ?? false; + } catch (e) { + print('减小音量失败: $e'); + return false; + } + } + + /// 命令结果回调 + Stream get onCommandResult => commandResultStream + .where((event) => event.commandType == commandTypeVolume) + .map((event) => event); +} + +/// 蓝牙设备模型 +class BluetoothDevice { + // ... existing code ... +} + +/// 命令结果 +class CommandResult { + final int commandType; + final bool success; + + CommandResult({ + required this.commandType, + required this.success, + }); + + @override + String toString() => 'CommandResult{commandType: $commandType, success: $success}'; +} \ No newline at end of file diff --git a/local_plugins/jieli/pubspec.yaml b/local_plugins/jieli/pubspec.yaml new file mode 100644 index 000000000..ab230e685 --- /dev/null +++ b/local_plugins/jieli/pubspec.yaml @@ -0,0 +1,23 @@ +name: jieli +description: A Flutter plugin for connecting with Jieli BLE earphones +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.jieli + pluginClass: JieliPlugin \ No newline at end of file diff --git a/local_plugins/open_ai_service/LICENSE b/local_plugins/open_ai_service/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/open_ai_service/README.md b/local_plugins/open_ai_service/README.md new file mode 100644 index 000000000..351eee84f --- /dev/null +++ b/local_plugins/open_ai_service/README.md @@ -0,0 +1,253 @@ +# OpenAI Service Plugin + +一个用于Flutter应用的OpenAI服务插件,支持Android和iOS平台。 + +## 功能 + +- 支持文本生成(completions) +- 支持流式输出(streaming) +- 支持函数调用(function calling) +- 支持自定义API基础URL +- 支持自定义模型选择 + +## 安装 + +在你的`pubspec.yaml`文件中添加以下依赖: + +```yaml +dependencies: + open_ai_service: + path: 本地路径/open_ai_service +``` + +## 使用方法 + +### 初始化服务 + +```dart +import 'package:open_ai_service/open_ai_service.dart'; + +final openAIService = OpenAIService(); + +// 初始化服务 +await openAIService.initialize( + apiKey: 'your_openai_api_key', + baseUrl: 'https://api.openai.com/v1/chat/completions', // 可选 + model: 'gpt-4-turbo', // 可选 +); +``` + +### 发送非流式请求 + +```dart +// 创建消息 +final userMessage = await openAIService.createUserMessage('你好,请介绍一下自己'); + +// 发送请求 +final response = await openAIService.sendMessage( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', +); + +print('AI回复: $response'); +``` + +### 发送流式请求(回调方式) + +```dart +// 创建消息 +final userMessage = await openAIService.createUserMessage('写一个短故事'); + +// 发送流式请求 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个善于讲故事的AI助手', +); + +// 处理事件 +final subscription = openAIService.processEvents( + onToken: (token) { + // 处理每个返回的token + print(token); + }, + onComplete: () { + // 处理完成事件 + print('生成完成'); + }, + onError: (error) { + // 处理错误 + print('错误: $error'); + }, + onFunctionCall: (functionCall) { + // 处理函数调用 + print('函数调用: ${functionCall['name']}'); + }, +); + +// 在不需要时取消订阅 +subscription.cancel(); +``` + +### 发送流式请求(Stream方式) + +```dart +// 创建消息 +final userMessage = await openAIService.createUserMessage('写一个短故事'); + +// 获取字符串流 +final stream = openAIService.streamMessage( + messages: [userMessage], + systemPrompt: '你是一个善于讲故事的AI助手', +); + +// 使用流 +final StringBuilder responseBuilder = StringBuilder(); + +stream.listen( + (token) { + // 处理每个token + responseBuilder.write(token); + print(token); // 实时输出 + }, + onDone: () { + // 流结束 + print('完整回复: ${responseBuilder.toString()}'); + }, + onError: (error) { + // 错误处理 + print('错误: $error'); + } +); +``` + +### 注册函数 + +```dart +// 注册一个函数 +await openAIService.registerFunction( + name: 'get_weather', + description: '获取指定城市的天气信息', + parameters: { + 'type': 'object', + 'properties': { + 'city': { + 'type': 'string', + 'description': '城市名称', + }, + 'date': { + 'type': 'string', + 'description': '日期,格式为YYYY-MM-DD', + }, + }, + 'required': ['city'], + }, +); +``` + +### 处理函数调用 + +```dart +// 创建消息 +final userMessage = await openAIService.createUserMessage('明天北京的天气如何?'); + +// 发送流式请求 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', +); + +// 处理事件 +openAIService.processEvents( + onToken: (token) { + print(token); + }, + onComplete: () { + print('生成完成'); + }, + onError: (error) { + print('错误: $error'); + }, + onFunctionCall: (functionCall) { + // 处理函数调用 + final name = functionCall['name']; + final arguments = functionCall['arguments']; + + print('收到函数调用: $name, 参数: $arguments'); + + // 假设处理了函数调用并获得结果 + final result = '{"temperature": 25, "condition": "sunny"}'; + + // 发送函数调用结果 + openAIService.sendFunctionCallResult( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', + functionCall: functionCall, + functionResult: result, + ); + }, +); +``` + +### 使用Stream API处理函数调用 + +```dart +// 创建消息和响应处理器 +final userMessage = await openAIService.createUserMessage('明天北京的天气如何?'); +final responseBuilder = StringBuilder(); + +// 处理事件流以捕获函数调用 +final subscription = openAIService.processEvents( + onFunctionCall: (functionCall) async { + // 取消当前事件监听 + subscription.cancel(); + + // 处理函数调用 + final name = functionCall['name']; + final arguments = functionCall['arguments']; + + print('收到函数调用: $name, 参数: $arguments'); + + // 假设处理了函数调用并获得结果 + final result = '{"temperature": 25, "condition": "sunny"}'; + + // 使用Stream API发送函数调用结果 + final resultStream = openAIService.streamFunctionResult( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', + functionCall: functionCall, + functionResult: result, + ); + + // 处理结果流 + resultStream.listen( + (token) { + responseBuilder.write(token); + print(token); // 实时输出 + }, + onDone: () { + print('完整回复: ${responseBuilder.toString()}'); + }, + onError: (error) { + print('错误: $error'); + } + ); + } +); + +// 启动请求 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', +); +``` + +## 注意事项 + +1. 确保在使用前已正确初始化服务 +2. 对于流式请求,确保在不需要时取消订阅 +3. 处理函数调用时,确保提供有效的结果格式 +4. 网络请求可能会失败,请确保加入适当的错误处理 + +## 许可证 + +[MIT License](LICENSE) \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/AndroidManifest.xml b/local_plugins/open_ai_service/android/AndroidManifest.xml new file mode 100644 index 000000000..1d834e578 --- /dev/null +++ b/local_plugins/open_ai_service/android/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/build.gradle.kts b/local_plugins/open_ai_service/android/build.gradle.kts new file mode 100644 index 000000000..1f079fa6c --- /dev/null +++ b/local_plugins/open_ai_service/android/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + // Android Library 插件 + id("com.android.library") + // Kotlin Android 插件 + id("org.jetbrains.kotlin.android") + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +android { + // 命名空间,对应你插件的包名(需与代码内包名保持一致) + namespace = "com.yunqiinnovation.open_ai_service" + + // 目标 SDK 版本 + compileSdk = 33 + + defaultConfig { + // 最低 SDK 版本 + minSdk = 21 + targetSdk = 33 + } + + // Java 语言级别兼容配置 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // Kotlin 语言级别 + kotlinOptions { + jvmTarget = "11" + } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") + implementation("com.squareup.okhttp3:okhttp:4.9.3") + implementation("com.squareup.okhttp3:logging-interceptor:4.9.3") + implementation("com.anthropic:anthropic-java:0.8.0") + implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") + implementation("io.ktor:ktor-client-core:3.1.2") + implementation("io.ktor:ktor-client-cio:3.1.2") + implementation("io.ktor:ktor-client-okhttp:3.1.2") + implementation("io.ktor:ktor-client-logging:3.1.2") +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/settings.gradle.kts b/local_plugins/open_ai_service/android/settings.gradle.kts new file mode 100644 index 000000000..1c33f4971 --- /dev/null +++ b/local_plugins/open_ai_service/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "open_ai_service" \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml b/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e38cdeb21 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt new file mode 100644 index 000000000..15f0dc761 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -0,0 +1,892 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.util.Log +import com.anthropic.core.JsonValue +import com.anthropic.models.messages.Tool +import com.anthropic.models.messages.ToolUnion +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.* +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import org.json.JSONObject +import kotlin.jvm.optionals.getOrNull +import java.io.File +import io.ktor.client.engine.okhttp.* +import okhttp3.OkHttpClient +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.Interceptor +import io.ktor.client.request.* +import io.ktor.http.* +import com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport +import io.ktor.util.valuesOf + +/** + * 工具类型枚举 + */ +enum class ToolType { + LOCAL_FUNCTION, // 本地函数 + MCP_TOOL // MCP工具 +} + +/** + * 函数处理器接口 + */ +interface FunctionHandler { + /** + * 处理函数调用 + * @param arguments 函数参数,Map格式 + * @return 函数执行结果,字符串 + */ + suspend fun handle(arguments: Map): String +} + +/** + * 单个MCP子客户端 + * 负责连接到一个具体的MCP服务器并管理其工具 + */ +class MCPSubClient( + private val serverId: String, + serverUrl: String, + private val customClient: HttpClient? = null +) : AutoCloseable { + private val TAG = "MCPSubClient[$serverId]" + + // 保存服务器URL + private val serverUrl: String = serverUrl + + init { + Log.d(TAG, "MCPSubClient初始化,URL: $serverUrl") + } + + // 初始化MCP客户端 + private val mcp: Client = Client( + clientInfo = Implementation( + name = "deep-voice-mcp-client-$serverId", + version = "1.0.0" + ) + ) + + // 服务器提供的工具列表 + private var tools: List = emptyList() + + // 工具Map列表,用于OpenAI请求 + private val toolMaps = mutableListOf>() + + // 是否已连接 + private var isConnected = false + + // SSE传输层,使用自定义的传输层 + private var sseTransport: CustomSseClientTransport? = null + + // 工具名称到工具的映射,用于快速查找 + private val toolMapping = mutableMapOf() + + /** + * 将JsonObject转换为JsonValue + */ + private fun JsonObject.toJsonValue(): JsonValue { + val mapper = ObjectMapper() + val node = mapper.readTree(this.toString()) + return JsonValue.fromJsonNode(node) + } + + /** + * 连接到MCP服务器(通过SSE) + * @return 是否连接成功 + */ + suspend fun connect(): Boolean { + try { + Log.d(TAG, "开始连接到服务器: $serverUrl") + + // 使用提供的客户端或创建带SSL配置的客户端 + val client = customClient ?: createSslTrustAllClient() + + // 使用自定义传输层,直接传递完整URL + sseTransport = CustomSseClientTransport( + client = client, + urlString = serverUrl, + requestBuilder = { + // 添加基本请求头 + headers { + append(HttpHeaders.Accept, "text/event-stream") + append(HttpHeaders.CacheControl, "no-cache") + } + } + ) + + Log.d(TAG, "创建自定义SSE传输层成功") + + // 连接MCP客户端到服务器 + mcp.connect(sseTransport!!) + + // 获取服务器提供的工具列表 + val toolsResult = mcp.listTools() + Log.d(TAG, "获取到工具列表: $toolsResult") + + // 处理工具列表 + processToolsList(toolsResult) + + isConnected = true + Log.d(TAG, "成功连接到MCP服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") + return true + } catch (e: Exception) { + Log.e(TAG, "连接服务器失败: ${e.message}", e) + // 记录详细的错误堆栈 + Log.e(TAG, "详细错误: ${e.stackTraceToString()}") + return false + } + } + + /** + * 处理工具列表 + */ + private fun processToolsList(toolsResult: ListToolsResult?) { + tools = toolsResult?.tools?.map { tool -> + // 确保输入模式有效 + val inputType = tool.inputSchema?.type ?: "object" + val inputProperties = tool.inputSchema?.properties ?: emptyMap() + val inputRequired = tool.inputSchema?.required ?: emptyList() + + ToolUnion.ofTool( + Tool.builder() + .name(tool.name) + .description(tool.description ?: "") + .inputSchema( + Tool.InputSchema.builder() + .type(JsonValue.from(inputType)) + .properties(if (inputProperties is JsonObject) inputProperties.toJsonValue() else JsonValue.from(emptyMap())) + .putAdditionalProperty("required", JsonValue.from(inputRequired)) + .build() + ) + .build() + ) + } ?: emptyList() + + // 更新工具映射 + tools.forEach { toolUnion -> + val tool = toolUnion.tool().get() + toolMapping[tool.name()] = toolUnion + } + + // 更新工具Map列表 + updateToolMaps() + } + + /** + * 更新工具Map列表,用于OpenAI请求 + */ + private fun updateToolMaps() { + toolMaps.clear() + + // 添加MCP工具 + tools.forEach { toolUnion -> + try { + val toolOptional = toolUnion.tool() + if (!toolOptional.isPresent) { + return@forEach + } + + val tool = toolOptional.get() + + // 创建基本参数结构 + val parametersMap = mutableMapOf() + parametersMap["type"] = "object" // 默认值 + parametersMap["properties"] = mutableMapOf() + parametersMap["required"] = emptyList() + + // 从工具的输入模式中提取信息 + try { + // 获取输入模式并转换为字符串 + val schema = tool.inputSchema() + val schemaStr = schema.toString() + Log.d(TAG, "解析工具Schema: $schemaStr") + + // 如果toString()提供了足够的信息,就使用正则表达式解析 + // 这是最可靠的方法,因为它不依赖于API的细节变化 + + // 解析类型 + val typePattern = "type=([^,}]+)".toRegex() + val typeMatch = typePattern.find(schemaStr) + if (typeMatch != null) { + parametersMap["type"] = typeMatch.groupValues[1].trim() + } + + // 尝试从字符串中解析完整的JSON结构 + try { + // 直接从Schema字符串中提取属性结构,手动构建properties映射 + val propertiesMap = mutableMapOf() + + // 匹配属性定义: 形如 fieldName={...} + val propertiesPattern = "properties=\\{([^}]+)\\}".toRegex() + val propertiesMatch = propertiesPattern.find(schemaStr) + + if (propertiesMatch != null) { + // 匹配属性定义: 形如 fieldName={...} + val fieldPattern = "([\\w]+)=\\{([^{}]+(?:\\{[^{}]*\\}[^{}]*)*)\\}".toRegex() + val fieldMatches = fieldPattern.findAll(propertiesMatch.groupValues[1]) + + fieldMatches.forEach { fieldMatch -> + val fieldName = fieldMatch.groupValues[1] + val fieldContent = fieldMatch.groupValues[2] + + // 为属性创建一个Map + val fieldMap = mutableMapOf() + + // 解析字段内容中的键值对 + val keyValuePattern = "(\\w+)=([^,}]+)".toRegex() + val keyValueMatches = keyValuePattern.findAll(fieldContent) + + keyValueMatches.forEach { kvMatch -> + val key = kvMatch.groupValues[1] + val value = kvMatch.groupValues[2].trim() + + // 根据值的类型进行适当处理 + val processedValue: Any = when { + value == "true" -> true + value == "false" -> false + value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value + value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value + else -> value // 保持为字符串 + } + + fieldMap[key] = processedValue + } + + propertiesMap[fieldName] = fieldMap + } + parametersMap["properties"] = propertiesMap + Log.d(TAG, "成功解析属性: $propertiesMap") + } else { + parametersMap["properties"] = emptyMap() + } + + // 提取required部分 + val requiredPattern = "required=\\[([^\\]]+)\\]".toRegex() + val requiredMatch = requiredPattern.find(schemaStr) + + if (requiredMatch != null) { + val requiredStr = requiredMatch.groupValues[1] + val requiredList = requiredStr.split(",").map { it.trim() } + parametersMap["required"] = requiredList + } else { + parametersMap["required"] = emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "从字符串解析schema细节失败: ${e.message}") + // 使用默认值 + parametersMap["properties"] = emptyMap() + parametersMap["required"] = emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "解析工具输入模式失败: ${e.message}", e) + // 使用默认值继续 + } + + // 创建工具Map + toolMaps.add(mapOf( + "type" to "function", + "function" to mapOf( + "name" to tool.name(), + "description" to (tool.description() ?: ""), + "parameters" to parametersMap + ) + )) + + Log.d(TAG, "工具 ${tool.name()} 成功添加到工具映射") + + } catch (e: Exception) { + Log.e(TAG, "转换工具到Map失败: ${e.message}") + } + } + + Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具") + } + + /** + * 将JsonNode转换为Map + */ + private fun convertJsonNodeToMap(node: JsonNode): Any { + return when { + node.isTextual() -> node.asText() + node.isNumber() -> node.asDouble() + node.isBoolean() -> node.asBoolean() + node.isObject() -> { + val result = mutableMapOf() + node.fieldNames().forEach { fieldName -> + result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) + } + result + } + node.isArray() -> { + val result = mutableListOf() + for (i in 0 until node.size()) { + result.add(convertJsonNodeToMap(node.get(i))) + } + result + } + node.isNull() -> "null" + else -> node.toString() + } + } + + /** + * 获取工具Map列表,用于OpenAI请求 + */ + fun getToolMaps(): List> { + return toolMaps + } + + /** + * 获取可用的工具列表 + */ + fun getTools(): List { + return tools + } + + /** + * 检查是否包含指定工具 + */ + fun containsTool(name: String): Boolean { + return toolMapping.containsKey(name) + } + + /** + * 调用工具 + */ + suspend fun callTool(name: String, arguments: Map): JSONObject? { + if (!isConnected) { + Log.e(TAG, "未连接到MCP服务器", null) + return JSONObject().apply { put("context","未连接到MCP服务器") } + } + + if (!containsTool(name)) { + Log.w(TAG, "此客户端不包含工具: $name") + return null + } + + return try { + val result = mcp.callTool( + name = name, + arguments = arguments + ) + // 处理工具调用结果 + val context = result?.content?.joinToString("\n") { content -> + when (content) { + is TextContent -> content.text ?: "" + else -> "" + } + } + JSONObject().apply {put("meta",result?._meta?.toMap()); put("context",context) } + } catch (e: Exception) { + Log.e(TAG, "调用MCP工具失败: ${e.message}", e) + JSONObject().apply { put("context", "调用MCP工具失败: ${e.message}") } + } + } + + /** + * 检查是否已连接 + */ + fun isConnected(): Boolean { + return isConnected + } + + /** + * 关闭连接 + */ + override fun close() { + runBlocking { + try { + mcp.close() + sseTransport?.close() + isConnected = false + Log.d(TAG, "已关闭MCP服务器连接") + } catch (e: Exception) { + Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) + } + } + } +} + +/** + * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 + */ +class MCPClient(private val context: Context? = null) : AutoCloseable { + private val TAG = "MCPClient" + + // 本地函数Map,函数名 -> 处理器 + private val localFunctions = mutableMapOf() + + // 本地函数定义Map,函数名 -> 定义 + private val localFunctionDefs = mutableMapOf() + + // 子客户端列表,每个连接一个MCP服务器 + private val subClients = mutableMapOf() + + // 是否已连接 + private var isConnected = false + + // 系统功能处理器 + private val systemFunctionHandler = SystemFunctionHandler(context!!) + + // 共享的HttpClient,用于所有子客户端 + private val sharedHttpClient by lazy { createSslTrustAllClient() } + + init { + // 如果提供了Context,自动注册语音功能处理器 + context?.let { initializeVoiceFunctions(it) } + } + + /** + * 初始化语音功能处理器 + */ + private fun initializeVoiceFunctions(context: Context) { + try { + // 使用SystemFunctionHandler注册所有功能 + systemFunctionHandler.registerAllFunctions(this) + + Log.d(TAG, "语音功能初始化完成") + } catch (e: Exception) { + Log.e(TAG, "语音功能初始化失败: ${e.message}", e) + } + } + + /** + * 连接到MCP服务器(通过SSE) + * 直接接收完整的JSON配置字符串 + * + * @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 + * @return 是否连接成功 + */ + suspend fun connectToSSE(mcpConfigJson: String): Boolean { + // 清除现有连接 + closeAllConnections() + + val serverMap = mutableMapOf() + var configLoaded = false + + // 尝试解析传入的JSON配置 + try { + if (mcpConfigJson.isNotEmpty()) { + val mapper = ObjectMapper() + try { + val config = mapper.readValue(mcpConfigJson, Map::class.java) + + // 处理mcpServers格式 + @Suppress("UNCHECKED_CAST") + val mcpServers = config["mcpServers"] as? Map> + if (mcpServers != null && mcpServers.isNotEmpty()) { + // 从服务器配置中提取URL + mcpServers.forEach { (serverId, serverConfig) -> + val url = serverConfig["url"] + if (!url.isNullOrEmpty()) { + serverMap[serverId] = url + Log.d(TAG, "从配置中添加服务器 [$serverId]: $url") + } + } + configLoaded = serverMap.isNotEmpty() + Log.d(TAG, "从传入的JSON配置加载了 ${serverMap.size} 个服务器") + } + } catch (e: Exception) { + Log.e(TAG, "解析传入的JSON配置失败: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "处理传入的配置失败: ${e.message}") + } + + + // 创建并连接所有子客户端 + val connectionResults = mutableMapOf() + + for ((serverId, serverUrl) in serverMap) { + try { + Log.d(TAG, "为服务器 [$serverId],[$serverUrl] 创建子客户端") + val subClient = MCPSubClient(serverId, serverUrl, sharedHttpClient) + val connected = subClient.connect() + + if (connected) { + subClients[serverId] = subClient + connectionResults[serverId] = true + Log.d(TAG, "子客户端 [$serverId] 连接成功") + } else { + subClient.close() + connectionResults[serverId] = false + Log.w(TAG, "子客户端 [$serverId] 连接失败") + } + } catch (e: Exception) { + Log.e(TAG, "创建子客户端 [$serverId] 失败: ${e.message}", e) + connectionResults[serverId] = false + } + } + + // 检查是否有任何客户端连接成功 + val successCount = connectionResults.count { it.value } + isConnected = successCount > 0 + + if (isConnected) { + Log.d(TAG, "已成功连接 $successCount/${serverMap.size} 个MCP服务器") + + // 记录所有可用工具 + val allToolNames = getAllToolNames() + Log.d(TAG, "所有可用工具: ${allToolNames.joinToString(", ")}") + + return true + } else { + Log.e(TAG, "所有MCP服务器连接均失败") + return false + } + } + + /** + * 关闭所有连接 + */ + private fun closeAllConnections() { + subClients.forEach { (serverId, client) -> + try { + client.close() + Log.d(TAG, "已关闭子客户端 [$serverId]") + } catch (e: Exception) { + Log.e(TAG, "关闭子客户端 [$serverId] 失败: ${e.message}") + } + } + subClients.clear() + } + + /** + * 获取所有工具名称 + */ + private fun getAllToolNames(): List { + val toolNames = mutableSetOf() + + // 添加本地函数 + toolNames.addAll(localFunctions.keys) + + // 添加所有子客户端的工具 + subClients.forEach { (_, client) -> + client.getTools().forEach { toolUnion -> + val tool = toolUnion.tool().get() + toolNames.add(tool.name()) + } + } + + return toolNames.toList() + } + + /** + * 注册本地函数 + * @param name 函数名称 + * @param description 函数描述 + * @param parameters 函数参数定义(Map或JSONObject) + * @param handler 函数处理器 + * @return 是否注册成功 + */ + fun registerLocalFunction( + name: String, + description: String, + parameters: Any, + handler: FunctionHandler + ): Boolean { + try { + // 将参数统一转换为Map格式 + val parametersMap: Map = when (parameters) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + parameters as Map + } + is JSONObject -> { + val mapper = ObjectMapper() + mapper.readValue( + parameters.toString(), + object : TypeReference>() {} + ) + } + else -> { + Log.e(TAG, "参数类型不支持: ${parameters.javaClass.name}") + return false + } + } + + // 检查参数是否包含必要字段 + if (!parametersMap.containsKey("type") || (parametersMap["type"] != "object")) { + Log.e(TAG, "参数必须是object类型") + return false + } + + // 注册函数处理器 + localFunctions[name] = handler + + // 创建函数定义 + val functionDef = mapOf( + "name" to name, + "description" to description, + "parameters" to parametersMap + ) + + // 保存函数定义 + localFunctionDefs[name] = ObjectMapper().writeValueAsString(functionDef) + + Log.d(TAG, "注册本地函数成功: $name") + return true + } catch (e: Exception) { + Log.e(TAG, "注册本地函数失败: ${e.message}", e) + return false + } + } + + /** + * 取消注册本地函数 + * @param name 函数名称 + * @return 是否取消成功 + */ + fun unregisterLocalFunction(name: String): Boolean { + val removed = localFunctions.remove(name) != null + if (removed) { + localFunctionDefs.remove(name) + Log.d(TAG, "取消注册本地函数成功: $name") + } + return removed + } + + /** + * 获取工具Map列表,用于OpenAI请求 + */ + fun getToolMaps(): List> { + val allToolMaps = mutableListOf>() + + // 首先添加本地函数 + localFunctionDefs.forEach { (name, functionDefJson) -> + try { + val mapper = ObjectMapper() + val functionDef = mapper.readValue(functionDefJson, object : TypeReference>() {}) + allToolMaps.add(mapOf( + "type" to "function", + "function" to functionDef + )) + } catch (e: Exception) { + Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}") + } + } + + // 然后添加所有子客户端的工具 + subClients.forEach { (serverId, client) -> + val clientTools = client.getToolMaps() + allToolMaps.addAll(clientTools) + Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") + } + + return allToolMaps + } + + /** + * 获取工具类型 + * @param name 工具名称 + * @return 工具类型,如果不存在则返回null + */ + fun getToolType(name: String): ToolType? { + if (localFunctions.containsKey(name)) { + return ToolType.LOCAL_FUNCTION + } + + if (subClients.any { it.value.containsTool(name) }) { + return ToolType.MCP_TOOL + } + + return null + } + + /** + * 调用工具(统一入口) + * @param name 工具名称 + * @param arguments 参数 + * @return 调用结果 + */ + suspend fun callTool(name: String, arguments: Map): JSONObject? { + val toolType = getToolType(name) + + return when (toolType) { + ToolType.LOCAL_FUNCTION -> { + callLocalFunction(name, arguments) + } + ToolType.MCP_TOOL -> { + callMcpTool(name, arguments) + } + null -> { + Log.e(TAG, "未找到工具: $name") + null + } + } + } + + /** + * 调用本地函数 + */ + private suspend fun callLocalFunction(name: String, arguments: Map): JSONObject? { + return try { + val handler = localFunctions[name] + if (handler != null) { + val context = handler.handle(arguments) + JSONObject().apply { put("context", context) } + } else { + Log.e(TAG, "未找到本地函数: $name") + null + } + } catch (e: Exception) { + Log.e(TAG, "调用本地函数失败: ${e.message}", e) + + JSONObject().apply { put("context", "调用本地函数失败: ${e.message}") } + } + } + + /** + * 调用MCP工具 + */ + private suspend fun callMcpTool(name: String, arguments: Map): JSONObject? { + if (!isConnected) { + Log.e(TAG, "未连接到MCP服务器", null) + return JSONObject().apply { put("context", "未连接到MCP服务器") } + } + + // 查找包含此工具的子客户端 + val clientEntry = subClients.entries.find { it.value.containsTool(name) } + + if (clientEntry != null) { + val (serverId, client) = clientEntry + Log.d(TAG, "通过子客户端 [$serverId] 调用工具: $name") + return client.callTool(name, arguments) + } + + // 没有找到包含此工具的客户端 + Log.e(TAG, "没有子客户端包含工具: $name") + return JSONObject().apply { put("context", "没有可用的服务器提供工具: $name") } + } + + /** + * 获取可用的工具列表 + */ + fun getTools(): List { + val allTools = mutableListOf() + + // 收集所有子客户端的工具 + subClients.forEach { (_, client) -> + allTools.addAll(client.getTools()) + } + + return allTools + } + + /** + * 检查工具是否存在 + */ + fun hasToolWithName(name: String): Boolean { + return localFunctions.containsKey(name) || + subClients.any { it.value.containsTool(name) } + } + + /** + * 从字符串解析JSON参数 + */ + fun parseJsonArguments(json: String): Map { + return try { + ObjectMapper().readValue(json, object : TypeReference>() {}) + } catch (e: Exception) { + Log.e(TAG, "解析JSON参数失败: ${e.message}", e) + emptyMap() + } + } + + /** + * 检查是否已连接到MCP服务器 + */ + fun isConnected(): Boolean { + return isConnected + } + + /** + * 关闭连接 + */ + override fun close() { + runBlocking { + try { + closeAllConnections() + isConnected = false + Log.d(TAG, "已关闭所有MCP服务器连接") + } catch (e: Exception) { + Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) + } + } + } +} + +/** + * 创建一个信任所有SSL证书的HttpClient + */ +private fun createSslTrustAllClient(): HttpClient { + // 创建信任所有证书的TrustManager + val trustAllCerts = arrayOf( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + ) + + // 创建SSL上下文并初始化它 + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, SecureRandom()) + + // 创建HTTP日志拦截器 + val loggingInterceptor = HttpLoggingInterceptor { message -> + Log.d("OkHttp", message) + }.apply { + level = HttpLoggingInterceptor.Level.HEADERS + } + + // 创建自定义拦截器显示实际请求URL + val urlLoggingInterceptor = Interceptor { chain -> + val request = chain.request() + Log.d("OkHttp-URL", "实际请求URL: ${request.url}") + Log.d("OkHttp-Headers", "请求头: ${request.headers}") + + val response = chain.proceed(request) + + Log.d("OkHttp-Response", "状态码: ${response.code} - ${response.message}") + Log.d("OkHttp-Response", "响应头: ${response.headers}") + + response + } + + // 创建OkHttpClient并配置信任所有证书 + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 + .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 + .build() + + // 创建使用OkHttp引擎的HttpClient + return HttpClient(OkHttp) { + engine { + preconfigured = okHttpClient + } + + // 安装SSE插件 + install(SSE) + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt new file mode 100644 index 000000000..b659ba411 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -0,0 +1,627 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.util.Log +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + + +/** + * OpenAI服务的原生实现 + */ +class OpenAIService(private val context: Context? = null) { + private val TAG = "OpenAIService" + + private var baseUrl = "" + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private var apiKey: String = "" + private var isInitialized = false + private var model: String = "" // 默认模型 + + // MCP客户端 + private var mcpClient: MCPClient? = null + private var isMcpInitialized = false + + // 是否自动处理MCP工具调用 + private var autoHandleMcpTools = true + + /** + * 构建curl命令用于测试 + */ + private fun buildCurlCommand(request: Request, body: String): String { + val command = StringBuilder("curl -v -X ${request.method}") + + // 添加请求头 + request.headers.forEach { header -> + // 敏感信息处理:不显示真实的API Key + if (header.first == "Authorization") { + command.append(" -H '${header.first}: Bearer $apiKey'") + } else { + command.append(" -H '${header.first}: ${header.second}'") + } + } + + // 添加请求体 + if (request.method == "POST" || request.method == "PUT") { + // 转义JSON中的单引号,确保curl命令正确 + val escapedBody = body.replace("'", "\\'") + command.append(" -d '${escapedBody}'") + } + + // 添加URL + command.append(" '${request.url}'") + + return command.toString() + } + + /** + * 创建用户消息 + */ + fun createUserMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "user") + put("content", content) + } + } + + /** + * 创建助手消息 + */ + fun createAssistantMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "assistant") + put("content", content) + } + } + + /** + * 初始化OpenAI服务 + */ + fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean { + this.apiKey = apiKey + if (baseUrl.isNotEmpty()) { + this.baseUrl = baseUrl + } + if (model.isNotEmpty()) { + this.model = model + } + + // 初始化MCPClient + if (mcpClient == null) { + mcpClient = MCPClient(context) + } + initializeMcpClient(mcpServer) + isInitialized = apiKey.isNotEmpty() + return isInitialized + } + + /** + * MCP客户端是否已初始化 + */ + fun isMcpInitialized(): Boolean { + return isMcpInitialized && mcpClient?.isConnected() == true + } + + /** + * 关闭MCP客户端 + */ + fun closeMcpClient() { + mcpClient?.close() + mcpClient = null + isMcpInitialized = false + } + + /** + * 初始化MCP客户端 + */ + fun initializeMcpClient(mcpServer: String): Boolean { + if (mcpClient != null) { + mcpClient?.close() + } + + mcpClient = MCPClient(context) + + // 在后台线程中初始化MCP客户端 + val scope = CoroutineScope(Dispatchers.IO) + + var result = false + scope.launch { + try { + result = mcpClient?.connectToSSE(mcpServer) ?: false + isMcpInitialized = result + Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}") + } catch (e: Exception) { + Log.e(TAG, "MCP客户端初始化失败: ${e.message}", e) + isMcpInitialized = false + } + } + + return true // 立即返回,实际连接在后台进行 + } + + + + /** + * 处理MCP工具调用 + * + * @param functionCall 函数调用JSON对象 + * @return 处理结果,如果处理失败则返回null + */ + suspend fun handleMcpToolCall(functionCall: JSONObject): JSONObject? { + if (mcpClient == null || !isMcpInitialized) { + return JSONObject().apply { put("context", "MCP客户端未初始化") } + } + + try { + // 获取函数名称 + val name = functionCall.getString("name") + + // 获取参数 + val argumentsJson = functionCall.getString("arguments") + val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() + + // 调用工具 + return mcpClient?.callTool(name, arguments) + } catch (e: Exception) { + Log.e(TAG, "处理MCP工具调用失败: ${e.message}", e) + return JSONObject().apply { put("context", "处理MCP工具调用失败: ${e.message}") } + } + } + + /** + * 注册函数 + */ + fun registerFunction(name: String, description: String, parameters: JSONObject): Boolean { + try { + // 确保MCPClient已初始化 + if (mcpClient == null) { + mcpClient = MCPClient() + } + + // 创建函数处理器 + val handler = object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + // 由于本地函数的实际处理是在Flutter端完成的 + // 这里只需返回一个标记,表示该函数是本地函数 + return "LOCAL_FUNCTION:$name" + } + } + + // 直接委托给MCPClient处理 + return mcpClient?.registerLocalFunction(name, description, parameters, handler) ?: false + } catch (e: Exception) { + Log.e(TAG, "注册函数失败: ${e.message}", e) + return false + } + } + + /** + * 自动处理MCP工具调用 + */ + private suspend fun autoHandleMcpToolCall(functionCall: JSONObject, messages: JSONArray, callback: StreamCallback) { + try { + // 获取函数名称 + val name = functionCall.getString("name") + + // 获取参数 + val argumentsJson = functionCall.getString("arguments") + val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() + + // 调用工具(统一通过MCPClient处理) + val result = mcpClient?.callTool(name, arguments) + var context = "工具调用失败"; + if (result != null){ + callback.onFunctionCall(result) + context = "${result["context"]}" + } + + // 不论是本地函数还是MCP工具,都直接将结果发送回OpenAI + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = context, + callback = callback + ) + } catch (e: Exception) { + Log.e(TAG, "自动处理工具调用失败: ${e.message}", e) + // 失败时返回错误给回调函数 + val errorMessage = "工具调用失败: ${e.message}" + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = errorMessage, + callback = callback + ) + } + } + + /** + * 发送消息(非流式输出) + */ + @Throws(OpenAIException::class) + fun sendMessage(messages: JSONArray): String { + if (!isInitialized || apiKey.isEmpty()) { + throw OpenAIException("OpenAI服务未初始化") + } + + val requestBody = JSONObject().apply { + put("model", model) + put("messages", messages) + put("temperature", 0.7) + put("max_tokens", 2000) + put("stream", false) + + // 添加工具列表 + val tools = JSONArray() + + // 使用MCPClient提供的所有工具 + mcpClient?.getToolMaps()?.forEach { toolMap -> + try { + val tool = JSONObject(toolMap) + tools.put(tool) + } catch (e: Exception) { + Log.e(TAG, "转换工具失败: ${e.message}", e) + } + } + + // 如果有工具,则添加到请求中 + if (tools.length() > 0) { + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + try { + // 输出用于测试的curl命令 + // val curlCommand = buildCurlCommand(request, requestBody.toString()) + // Log.d(TAG, "curl command: \n$curlCommand") + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw OpenAIException("API调用失败: ${response.code}") + } + + val responseBody = response.body?.string() ?: throw OpenAIException("Empty response") + val jsonResponse = JSONObject(responseBody) + + // 检查是否有函数调用 + if (jsonResponse.has("choices") && + jsonResponse.getJSONArray("choices").length() > 0) { + + val choice = jsonResponse.getJSONArray("choices").getJSONObject(0) + + // 检查是否是函数调用 + if (choice.has("message")) { + val message = choice.getJSONObject("message") + + // 检查是否有工具调用 + if (message.has("tool_calls")) { + val toolCalls = message.getJSONArray("tool_calls") + if (toolCalls.length() > 0) { + val toolCall = toolCalls.getJSONObject(0) + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + val functionCall = JSONObject().apply { + put("name", function.getString("name")) + put("arguments", function.getString("arguments")) + put("id", toolCall.getString("id")) + } + return functionCall.toString() + } + } + } + + // 如果没有工具调用,返回消息内容 + if (message.has("content")) { + return message.getString("content") + } + } + } + + throw OpenAIException("Invalid response format") + } + } catch (e: Exception) { + if (e is OpenAIException) throw e + throw OpenAIException("Failed to communicate with AI service: ${e.message}") + } + } + + /** + * 发送消息(流式输出) + */ + fun sendMessageStream(messages: JSONArray, callback: StreamCallback) { + if (!isInitialized || apiKey.isEmpty()) { + callback.onError(OpenAIException("OpenAI服务未初始化")) + return + } + + val requestBody = JSONObject().apply { + put("model", model) + put("messages", messages) + put("temperature", 0.7) + put("max_tokens", 2000) + put("stream", true) + + // 添加工具列表 + val tools = JSONArray() + + // 使用MCPClient提供的所有工具 + mcpClient?.getToolMaps()?.forEach { toolMap -> + try { + val tool = JSONObject(toolMap) + tools.put(tool) + } catch (e: Exception) { + Log.e(TAG, "转换工具失败: ${e.message}", e) + } + } + + // 如果有工具,则添加到请求中 + if (tools.length() > 0) { + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Accept", "text/event-stream") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + // 输出用于测试的curl命令 + val curlCommand = buildCurlCommand(request, requestBody.toString()) + Log.d(TAG, "curl command: $curlCommand") + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + callback.onError(OpenAIException(e.message ?: "请求失败")) + } + + override fun onResponse(call: Call, response: Response) { + + if (!response.isSuccessful) { + callback.onError(OpenAIException("API调用失败: ${response.code}")) + return + } + + val responseBody = response.body ?: return + val source = responseBody.source() + val buffer = Buffer() + + try { + // 用于存储函数调用的各个部分 + val finalToolCalls = mutableMapOf() + val lineBuffer = StringBuilder() + + // 逐块读取数据,而不是一次性预取 + val BUFFER_SIZE = 8192L // 8KB的块大小 + + while (!Thread.currentThread().isInterrupted) { + // 读取一小块数据 + val bytesRead = source.read(buffer, BUFFER_SIZE) + if (bytesRead == -1L) break // 流结束 + + // 处理接收到的数据 + while (true) { + val line = buffer.readUtf8Line() ?: break + if (line.trim().isEmpty()) continue + + if (line.startsWith("data:")) { + val data = line.substring(5).trim() + Log.d("OpenAIService", "$data") + + // 处理[DONE]消息 + if (data == "[DONE]" || data == "[\"DONE\"]") { + Log.d(TAG, "收到[DONE]消息") + val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + // 只有在没有工具调用时才认为对话真正完成 + if (!hasToolCalls) { + callback.onComplete() + } + return + } + + try { + val jsonData = JSONObject(data) + + // 处理消息内容 + if (jsonData.has("choices")) { + val choices = jsonData.getJSONArray("choices") + if (choices.length() > 0) { + val choice = choices.getJSONObject(0) + + if (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 处理普通文本内容 + if (delta.has("content")) { + val content = delta.getString("content") + callback.onToken(content) + } + + // 处理工具调用(函数调用) + if (delta.has("tool_calls")) { + val toolCalls = delta.getJSONArray("tool_calls") + for (i in 0 until toolCalls.length()) { + val toolCall = toolCalls.getJSONObject(i) + val index = toolCall.getInt("index") + + // 创建或获取现有的工具调用信息 + val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } + + // 更新ID + if (toolCall.has("id")) { + toolCallInfo.id = toolCall.getString("id") + } + + // 更新函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + toolCallInfo.name = function.getString("name") + } + + if (function.has("arguments")) { + toolCallInfo.arguments += function.getString("arguments") + } + } + } + } + } + } + } + } catch (e: Exception) { + // 忽略解析错误 + Log.e(TAG, "解析JSON出错: ${e.message}") + } + } + } + } + } catch (e: Exception) { + callback.onError(OpenAIException("处理响应流时出错: ${e.message}")) + } finally { + responseBody.close() + } + } + }) + } + + /** + * 发送函数调用结果 + */ + fun sendFunctionCallResult( + messages: JSONArray, + functionCall: JSONObject, + functionResult: String, + callback: StreamCallback + ) { + try { + val fullMessages = JSONArray() + + // 添加用户消息 + for (i in 0 until messages.length()) { + fullMessages.put(messages.getJSONObject(i)) + } + + // 添加函数调用消息 + fullMessages.put(JSONObject().apply { + put("role", "assistant") + put("content", "") + + // 添加工具调用 + val toolCalls = JSONArray().apply { + val toolCall = JSONObject().apply { + put("id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + put("type", "function") + put("function", JSONObject().apply { + put("name", functionCall.getString("name")) + put("arguments", functionCall.getString("arguments")) + }) + } + put(toolCall) + } + put("tool_calls", toolCalls) + }) + + // 添加函数调用结果 + fullMessages.put(JSONObject().apply { + put("role", "tool") + put("content", functionResult) + put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + }) + + // 发送完整对话 + sendMessageStream(fullMessages, callback) + + } catch (e: Exception) { + callback.onError(OpenAIException("发送函数调用结果失败: ${e.message}")) + } + } + + /** + * 处理工具调用结果 + */ + private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null): Boolean { + if (toolCalls.isEmpty()) return false + + // 只处理第一个工具调用 + val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false + + if (firstToolCall.isValid()) { + // 创建函数调用JSON对象 + val functionCall = JSONObject().apply { + put("name", firstToolCall.name) + put("arguments", firstToolCall.arguments) + put("id", firstToolCall.id) + } + + // 在协程中处理工具调用 + if (messages != null) { + kotlinx.coroutines.GlobalScope.launch { + try { + autoHandleMcpToolCall(functionCall, messages, callback) + } catch (e: Exception) { + Log.e(TAG, "工具调用处理失败: ${e.message}", e) + } + } + } + return true + } + return false + } + + /** + * 工具调用信息类 + */ + private class ToolCallInfo { + var id: String = "" + var name: String = "" + var arguments: String = "" + + fun isValid(): Boolean { + return id.isNotEmpty() && name.isNotEmpty() + } + } + + /** + * 流式输出回调接口 + */ + interface StreamCallback { + fun onToken(token: String) + fun onComplete() + fun onError(e: Exception) + fun onFunctionCall(functionCall: JSONObject) + } +} + +/** + * OpenAI服务异常 + */ +class OpenAIException(message: String) : Exception(message) \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt new file mode 100644 index 000000000..40af80aa9 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -0,0 +1,389 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import kotlinx.serialization.json.Json +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +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 io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink +import io.flutter.plugin.common.EventChannel.StreamHandler +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** OpenAIServicePlugin */ +class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { + /// 方法通道名称 + private val methodChannelName = "com.yunqiinnovation.open_ai_service/methods" + + /// 事件通道名称 + private val eventChannelName = "com.yunqiinnovation.open_ai_service/events" + + /// 方法通道 + private lateinit var methodChannel: MethodChannel + + /// 事件通道 + private lateinit var eventChannel: EventChannel + + /// 应用上下文 + private lateinit var context: Context + + /// OpenAI服务实例 + private lateinit var openAIService: OpenAIService + + /// 事件接收器(用于流式输出) + private var eventSink: EventSink? = null + + /// 执行器(用于真正需要在后台线程处理的任务) + private val executor = Executors.newSingleThreadExecutor() + + /// 主线程Handler + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + // 保存上下文 + context = flutterPluginBinding.applicationContext + + // 初始化OpenAI服务 + openAIService = OpenAIService(context) + + // 初始化方法通道 + methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, methodChannelName) + methodChannel.setMethodCallHandler(this) + + // 初始化事件通道 + eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, eventChannelName) + eventChannel.setStreamHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val apiKey = call.argument("apiKey") ?: "" + val baseUrl = call.argument("baseUrl") ?: "" + val model = call.argument("model") ?: "" + val mcpServer = call.argument("mcpServer") ?: "" + + val initialized = openAIService.initialize(apiKey, baseUrl, model, mcpServer) + result.success(initialized) + } + + + "registerFunction" -> { + val name = call.argument("name") ?: "" + val description = call.argument("description") ?: "" + val parameters = call.argument>("parameters") + + if (name.isEmpty() || parameters == null) { + result.error("INVALID_ARGUMENT", "函数注册参数无效", null) + return + } + + val parametersJson = JSONObject(parameters) + val registered = openAIService.registerFunction(name, description, parametersJson) + result.success(registered) + } + + "sendMessage" -> { + val messagesRaw = call.argument>>("messages") ?: emptyList() + + // 转换消息格式 + val messages = JSONArray() + for (message in messagesRaw) { + messages.put(JSONObject(message)) + } + + // 对于需要在后台执行的同步阻塞操作,仍然使用executor + executor.execute { + try { + val response = openAIService.sendMessage(messages) + // 在主线程返回结果 + mainHandler.post { + result.success(response) + } + } catch (e: Exception) { + // 在主线程返回错误 + mainHandler.post { + result.error("OPENAI_ERROR", e.message, null) + } + } + } + } + + "sendMessageStream" -> { + val messagesRaw = call.argument>>("messages") ?: emptyList() + + // 检查事件接收器 + if (eventSink == null) { + result.error("NO_EVENT_SINK", "没有可用的事件流接收器", null) + return + } + + // 转换消息格式 + val messages = JSONArray() + for (message in messagesRaw) { + messages.put(JSONObject(message)) + } + + // 不再在后台线程包装OkHttp的异步调用 + try { + openAIService.sendMessageStream( + messages = messages, + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + // 发送token事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "token", "content" to token)) + } + } + + override fun onComplete() { + // 发送完成事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "complete")) + } + } + + override fun onError(e: Exception) { + // 发送错误事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "error", "content" to e.message)) + } + } + + override fun onFunctionCall(functionCall: JSONObject) { + Log.e("OpenAIServicePlugin", "工具调用结果: ${functionCall.toString()}") + val context = functionCall["context"] +// val meta = functionCall["meta"] + val meta: Map? = functionCall.optJSONObject("meta")?.let { metaJson -> + metaJson.keys().asSequence().associateWith { key -> + metaJson.get(key) // 或 opt(key),更安全 + } + } + val card_spotifyKey = meta?.containsKey("card_spotify") ?: false + if (card_spotifyKey) { //是否是音乐卡片 + + } + + // 发送函数调用事件 + mainHandler.post { +// val functionCallMap = functionCall.toMap() + eventSink?.success(mapOf("type" to "functionCall", "content" to context,"meta" to meta)) + } + } + } + ) + + // 请求已开始,直接返回结果 + result.success(true) + } catch (e: Exception) { + // 直接在当前线程处理错误 + result.error("OPENAI_ERROR", e.message, null) + } + } + + "sendFunctionCallResult" -> { + val messagesRaw = call.argument>>("messages") ?: emptyList() + val functionCallRaw = call.argument>("functionCall") ?: emptyMap() + val functionResult = call.argument("functionResult") ?: "" + + // 检查事件接收器 + if (eventSink == null) { + result.error("NO_EVENT_SINK", "没有可用的事件流接收器", null) + return + } + + // 转换消息格式 + val messages = JSONArray() + for (message in messagesRaw) { + messages.put(JSONObject(message)) + } + + // 转换函数调用 + val functionCall = JSONObject(functionCallRaw) + + // 不再在后台线程包装OkHttp的异步调用 + try { + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = functionResult, + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + // 发送token事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "token", "content" to token)) + } + } + + override fun onComplete() { + // 发送完成事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "complete")) + } + } + + override fun onError(e: Exception) { + // 发送错误事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "error", "content" to e.message)) + } + } + + override fun onFunctionCall(nestedFunctionCall: JSONObject) { + Log.e("OpenAIServicePlugin", "工具调用结果: ${nestedFunctionCall.toString()}") + // 发送函数调用事件 +// mainHandler.post { +// val functionCallMap = nestedFunctionCall.toMap() +// eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) +// } + } + } + ) + + // 请求已开始,直接返回结果 + result.success(true) + } catch (e: Exception) { + // 直接在当前线程处理错误 + result.error("OPENAI_ERROR", e.message, null) + } + } + + "createUserMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createUserMessage(content) + result.success(message.toMap()) + } + + "createAssistantMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createAssistantMessage(content) + result.success(message.toMap()) + } + + "initializeMcpClient" -> initializeMcpClient(call, result) + "isMcpInitialized" -> isMcpInitialized(call, result) + "closeMcpClient" -> closeMcpClient(call, result) + "handleMcpToolCall" -> handleMcpToolCall(call, result) + + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + executor.shutdown() + } + + // Stream事件处理 + override fun onListen(arguments: Any?, eventSink: EventSink?) { + this.eventSink = eventSink + } + + override fun onCancel(arguments: Any?) { + this.eventSink = null + } + + // 工具方法:JSONObject转Map + private fun JSONObject.toMap(): Map { + val map = mutableMapOf() + val keys = this.keys() + while (keys.hasNext()) { + val key = keys.next() + var value: Any? = this.opt(key) + + value = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } + + map[key] = value + } + return map + } + + // 工具方法:JSONArray转List + private fun JSONArray.toList(): List { + val list = mutableListOf() + for (i in 0 until this.length()) { + var value: Any? = this.opt(i) + + value = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } + + list.add(value) + } + return list + } + + private fun initializeMcpClient(call: MethodCall, result: Result) { + val serverUrl = call.argument("serverUrl") + if (serverUrl == null) { + result.error("INVALID_ARGUMENT", "Missing serverUrl", null) + return + } + + val success = openAIService.initializeMcpClient(serverUrl) + result.success(success) + } + + private fun isMcpInitialized(call: MethodCall, result: Result) { + result.success(openAIService.isMcpInitialized()) + } + + private fun closeMcpClient(call: MethodCall, result: Result) { + openAIService.closeMcpClient() + result.success(true) + } + + private fun handleMcpToolCall(call: MethodCall, result: Result) { + val functionCall = call.argument("functionCall") + if (functionCall == null) { + result.error("INVALID_ARGUMENT", "Missing functionCall", null) + return + } + + try { + val jsonFunctionCall = JSONObject(functionCall) + + // 使用协程处理异步操作 + CoroutineScope(Dispatchers.IO).launch { + try { + val toolResult = openAIService.handleMcpToolCall(jsonFunctionCall) + + // 切换到主线程返回结果 + withContext(Dispatchers.Main) { + result.success(toolResult ?: "无法处理MCP工具调用") + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + result.error("MCP_TOOL_ERROR", "处理MCP工具调用失败: ${e.message}", null) + } + } + } + } catch (e: Exception) { + result.error("INVALID_ARGUMENT", "解析functionCall失败: ${e.message}", null) + } + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt new file mode 100644 index 000000000..875895d65 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt @@ -0,0 +1,422 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.ContactsContract +import android.telephony.SmsManager +import android.util.Log +import org.json.JSONObject + +/** + * 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 + */ +class SystemFunctionHandler(private val context: Context) { + companion object { + private const val TAG = "SystemFunctionHandler" + const val ACTION_EXIT_INTERACTION = "com.yunqiinnovation.deepsound.action.EXIT_INTERACTION" + } + + /** + * 在MCPClient中注册所有系统功能 + * @param client MCPClient实例 + */ + fun registerAllFunctions(client: MCPClient) { + try { + // 注册退出交互函数 + registerExitInteractionFunction(client) + + // 注册翻译模式函数 + registerTranslationModeFunction(client) + + // 注册发送短信函数 + registerSendTextMessageFunction(client) + + // 注册拨打电话函数 + registerMakePhoneCallFunction(client) + + // 注册打开应用函数 + registerOpenAppFunction(client) + + Log.d(TAG, "所有系统功能注册完成") + } catch (e: Exception) { + Log.e(TAG, "注册系统功能失败: ${e.message}", e) + } + } + + + /** + * 注册退出交互函数 + */ + private fun registerExitInteractionFunction(client: MCPClient) { + client.registerLocalFunction( + "exit_interaction", + "退出当前语音交互", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + // 调用发送广播的函数 + sendExitInteractionBroadcast() + + return "{\"result\": \"已退出语音交互\"}" + } + } + ) + Log.d(TAG, "已注册退出交互功能") + } + + /** + * 注册翻译模式函数 + */ + private fun registerTranslationModeFunction(client: MCPClient) { + client.registerLocalFunction( + "enter_translation_mode", + "用户请求进入实时翻译模式时,启动实时翻译功能", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val result = enterTranslationMode() + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册翻译模式功能") + } + + /** + * 注册发送短信函数 + */ + private fun registerSendTextMessageFunction(client: MCPClient) { + client.registerLocalFunction( + "send_text_message", + "发送短信", + JSONObject(""" + { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" + } + }, + "required": ["contact", "message"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val contact = arguments["contact"] as? String ?: "" + val message = arguments["message"] as? String ?: "" + val result = sendTextMessage(contact, message) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册发送短信功能") + } + + /** + * 注册拨打电话函数 + */ + private fun registerMakePhoneCallFunction(client: MCPClient) { + client.registerLocalFunction( + "make_phone_call", + "拨打电话", + JSONObject(""" + { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": ["contact"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val contact = arguments["contact"] as? String ?: "" + val result = makePhoneCall(contact) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册拨打电话功能") + } + + /** + * 注册打开应用函数 + */ + private fun registerOpenAppFunction(client: MCPClient) { + client.registerLocalFunction( + "open_app", + "当用户请求打开特定应用时,启动相应应用", + JSONObject(""" + { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": ["app_name"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val appName = arguments["app_name"] as? String ?: "" + val result = openApp(appName) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册打开应用功能") + } + + + /** + * 发送退出交互广播 + */ + private fun sendExitInteractionBroadcast() { + // 发送广播通知VoiceInteractionHandler停止语音识别 + val intent = Intent(ACTION_EXIT_INTERACTION).apply { + // 明确指定接收广播的包名 + setPackage(context.packageName) + putExtra("timestamp", System.currentTimeMillis()) + // 添加FLAG_RECEIVER_FOREGROUND确保及时处理 + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + Log.d(TAG, "准备发送停止交互广播,包名: ${context.packageName}") + context.sendBroadcast(intent) + Log.d(TAG, "已发送停止交互广播") + } + + + + /** + * 启动翻译模式 + * @return 操作结果 + */ + fun enterTranslationMode(): Result { + Log.d(TAG, "进入翻译模式") + // 此处应添加实际的翻译模式实现 + // 可能需要启动一个专门的翻译服务或活动 + + // 广播通知APP的其他部分进入翻译模式 + val intent = Intent("com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE").apply { + setPackage(context.packageName) + + putExtra("timestamp", System.currentTimeMillis()) + // 添加FLAG_RECEIVER_FOREGROUND确保及时处理 + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + + // 发送广播 + context.sendBroadcast(intent) + Log.d(TAG, "已发送广播: ENTER_TRANSLATION_MODE") + + return Result( + success = true, + message = "已进入实时翻译模式" + ) + } + + /** + * 发送短信 + * @param contact 联系人(名称或电话号码) + * @param message 短信内容 + * @return 操作结果 + */ + fun sendTextMessage(contact: String, message: String): Result { + Log.d(TAG, "发送短信给 $contact: $message") + + try { + val phoneNumber = getPhoneNumberForContact(contact) + if (phoneNumber.isEmpty()) { + return Result( + success = false, + message = "未找到联系人信息: $contact" + ) + } + + // 使用SmsManager发送短信 + val smsManager = SmsManager.getDefault() + smsManager.sendTextMessage(phoneNumber, null, message, null, null) + + return Result( + success = true, + message = "已向${contact}发送短信: $message" + ) + } catch (e: Exception) { + Log.e(TAG, "发送短信失败: ${e.message}", e) + return Result( + success = false, + message = "发送短信失败: ${e.message}" + ) + } + } + + /** + * 拨打电话 + * @param contact 联系人(名称或电话号码) + * @return 操作结果 + */ + fun makePhoneCall(contact: String): Result { + Log.d(TAG, "拨打电话给 $contact") + + try { + val phoneNumber = getPhoneNumberForContact(contact) + if (phoneNumber.isEmpty()) { + return Result( + success = false, + message = "未找到联系人信息: $contact" + ) + } + + // 创建拨号意图 + val intent = Intent(Intent.ACTION_CALL) + intent.data = Uri.parse("tel:$phoneNumber") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + + return Result( + success = true, + message = "正在拨打${contact}的电话" + ) + } catch (e: Exception) { + Log.e(TAG, "拨打电话失败: ${e.message}", e) + return Result( + success = false, + message = "拨打电话失败: ${e.message}" + ) + } + } + + /** + * 打开应用程序 + * @param appName 应用名称 + * @return 操作结果 + */ + fun openApp(appName: String): Result { + Log.d(TAG, "打开应用: $appName") + + try { + // 查找应用的包名 + val packageManager = context.packageManager + val intent = Intent(Intent.ACTION_MAIN, null) + intent.addCategory(Intent.CATEGORY_LAUNCHER) + + val apps = packageManager.queryIntentActivities(intent, 0) + + for (resolveInfo in apps) { + val appLabel = resolveInfo.loadLabel(packageManager).toString() + if (appLabel.equals(appName, ignoreCase = true) || + appLabel.contains(appName, ignoreCase = true)) { + + // 找到匹配的应用 + val packageName = resolveInfo.activityInfo.packageName + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + + if (launchIntent != null) { + launchIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(launchIntent) + + return Result( + success = true, + message = "正在打开${appName}应用" + ) + } + } + } + + return Result( + success = false, + message = "未找到应用: $appName" + ) + } catch (e: Exception) { + Log.e(TAG, "打开应用失败: ${e.message}", e) + return Result( + success = false, + message = "打开应用失败: ${e.message}" + ) + } + } + + /** + * 根据联系人名称获取电话号码 + * @param contact 联系人名称或电话号码 + * @return 电话号码 + */ + private fun getPhoneNumberForContact(contact: String): String { + // 如果输入的是电话号码(简单判断,包含数字和特殊符号) + if (contact.matches(Regex("^[+\\d\\s\\-()]*\$")) && contact.any { it.isDigit() }) { + // 移除非数字字符 + return contact.replace(Regex("[^+\\d]"), "") + } + + // 从联系人数据库查询 + try { + val contentResolver = context.contentResolver + val uri = Uri.withAppendedPath( + ContactsContract.Contacts.CONTENT_FILTER_URI, + Uri.encode(contact) + ) + + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.Contacts.HAS_PHONE_NUMBER + ) + + contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val hasPhoneNumber = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + + if (hasPhoneNumber > 0) { + val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) + val phoneUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI + val phoneProjection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) + val phoneSelection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?" + val phoneSelectionArgs = arrayOf(contactId) + + contentResolver.query(phoneUri, phoneProjection, phoneSelection, phoneSelectionArgs, null)?.use { phoneCursor -> + if (phoneCursor.moveToFirst()) { + return phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) + } + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "查询联系人信息失败: ${e.message}", e) + } + + return "" + } + + /** + * 操作结果数据类 + */ + data class Result( + val success: Boolean, + val message: String + ) +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt new file mode 100644 index 000000000..005ebf734 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt @@ -0,0 +1,357 @@ +package com.yunqiinnovation.open_ai_service.mcp + +import android.util.Log +import io.ktor.client.* +import io.ktor.client.plugins.sse.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport +import kotlinx.coroutines.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString +import kotlin.properties.Delegates +import kotlin.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONObject + +/** + * 自定义SSE客户端传输层,修复原始SseClientTransport中的URL拼接问题 + * 解决URL查询参数与路径拼接错误的问题,确保消息端点URL格式正确 + */ +class CustomSseClientTransport( + private val client: HttpClient, + private val urlString: String?, + private val reconnectionTime: Duration? = null, + private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, +) : AbstractTransport() { + private val TAG = "CustomSseClientTransport" + + private val scope by lazy { + CoroutineScope(session.coroutineContext + SupervisorJob()) + } + + // 使用Java标准库的AtomicBoolean替代kotlinx.atomicfu + private val initialized = AtomicBoolean(false) + private var session: ClientSSESession by Delegates.notNull() + private val endpoint = CompletableDeferred() + + private var job: Job? = null + + // 创建JSON解析器,增强灵活性设置 + private val json = Json { + ignoreUnknownKeys = true // 忽略未知字段 + isLenient = true // 宽松解析模式 + coerceInputValues = true // 尝试强制转换类型 + encodeDefaults = true // 编码默认值 + explicitNulls = false // 不要求显式null值 + } + + // 保存基础URL(不包含查询参数)和查询参数 + private var baseUrlWithoutParams: String? = null + private var queryParams: Map = emptyMap() + private var hostPart: String = "" // 添加类级别变量 + private var pathPart: String = "" // 添加类级别变量 + + /** + * 解析URL,分离基础URL、路径和查询参数 + * 返回三元组: (主机部分URL, 路径部分, 查询参数Map) + */ + private fun parseUrl(url: String): Triple> { + return try { + val params = mutableMapOf() + + // 确保URL有协议部分 + var processedUrl = url.trim() + if (!processedUrl.startsWith("http://") && !processedUrl.startsWith("https://")) { + processedUrl = "https://$processedUrl" + Log.d(TAG, "添加默认协议: $processedUrl") + } + + val urlObj = java.net.URL(processedUrl) + + // 解析查询参数 + if (urlObj.query != null) { + urlObj.query.split("&").forEach { param -> + val parts = param.split("=", limit = 2) + if (parts.size == 2) { + params[parts[0]] = parts[1] + } + } + } + + // 构建主机部分URL(协议+主机+端口) + val port = if (urlObj.port == -1) "" else ":${urlObj.port}" + val hostUrl = "${urlObj.protocol}://${urlObj.host}$port" + + // 路径部分 + val path = urlObj.path + + Triple(hostUrl, path, params) + } catch (e: Exception) { + Log.e(TAG, "解析URL失败: $url, ${e.message}") + Triple(url, "", emptyMap()) + } + } + + /** + * 收集SSE事件 + */ + private suspend fun collectEvents() { + job = scope.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { + session.incoming.collect { event -> + when (event.event) { + "error" -> { + val e = IllegalStateException("SSE error: ${event.data}") + Log.e(TAG, "SSE错误: ${event.data}") + _onError(e) + throw e + } + + "open" -> { + Log.d(TAG, "SSE连接已打开") + // 连接已打开,等待endpoint事件 + } + + "endpoint" -> { + try { + val eventData = event.data ?: "" + Log.d(TAG, "收到endpoint事件: $eventData") + + // 使用主机部分构建endpoint + val fullEndpoint = if (eventData.startsWith("/")) { + "$hostPart$eventData" + } else { + "$eventData" + } + + Log.d(TAG, "构建的endpoint路径(不含参数): $fullEndpoint") + + // 添加查询参数到endpoint + val endpointWithParams = if (queryParams.isNotEmpty()) { + // 检查endpoint是否已有查询参数 + if (fullEndpoint.contains("?")) { + // 已有查询参数,添加&并附加其他参数 + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$fullEndpoint&$queryString" + } else { + // 没有查询参数,添加?并附加参数 + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$fullEndpoint?$queryString" + } + } else { + fullEndpoint + } + + Log.d(TAG, "最终消息端点: $endpointWithParams") + endpoint.complete(endpointWithParams) + } catch (e: Exception) { + Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) + _onError(e) + close() + error(e) + } + } + + else -> { + try { + // 解析JSON-RPC消息 + val data = event.data + if (data != null) { + Log.d(TAG, "收到事件数据: $data") + try { + // 尝试安全地解析JSON消息 + safeParseMessage(data) + } catch (e: Exception) { + Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) + // 错误已记录,但不中断连接,只发送错误通知 + _onError(e) + } + } + } catch (e: Exception) { + Log.e(TAG, "处理事件失败: ${e.message}", e) + _onError(e) + } + } + } + } + } + } + + /** + * 安全解析JSON-RPC消息 + */ + private suspend fun safeParseMessage(data: String) { + try { + // 先尝试使用标准解析 + val message = json.decodeFromString(data) + _onMessage(message) + } catch (e: Exception) { + // 如果标准解析失败,记录错误并尝试使用备用解析方式 + Log.w(TAG, "标准解析失败,尝试备用解析: ${e.message}") + + try { + // 尝试修复nextCursor缺失问题 + if (e.message?.contains("nextCursor") == true) { + // 尝试手动添加缺失的nextCursor字段 + val jsonObj = JSONObject(data) + + // 只有在解析ListToolsResult时处理 + if (data.contains("\"tools\"")) { + Log.d(TAG, "尝试修复ListToolsResult缺少nextCursor字段的问题") + + // 手动解析result部分并添加nextCursor + val resultJson = try { + if (jsonObj.has("result")) { + val resultObj = jsonObj.getJSONObject("result") + if (!resultObj.has("nextCursor")) { + resultObj.put("nextCursor", "") + jsonObj.put("result", resultObj) + } + jsonObj.toString() + } else { + // 如果没有result字段,可能是其他类型的消息 + data + } + } catch (ex: Exception) { + Log.e(TAG, "手动修复JSON失败: ${ex.message}") + data + } + + // 重新尝试解析修复后的JSON + val fixedMessage = json.decodeFromString(resultJson) + _onMessage(fixedMessage) + return + } + } + + // 通用错误处理 + Log.e(TAG, "无法解析消息,跳过: $data") + } catch (ex: Exception) { + Log.e(TAG, "备用解析也失败: ${ex.message}", ex) + // 不抛出异常,只记录错误 + _onError(e) + } + } + } + + /** + * 启动传输层 + */ + override suspend fun start() { + if (!initialized.compareAndSet(false, true)) { + Log.e(TAG, "传输层已经启动,不能重复启动") + error("CustomSseClientTransport already started!") + } + + // 解析URL和参数 + if (urlString != null) { + // 解析URL,提取主机部分、路径部分和查询参数 + val urlInfo = parseUrl(urlString) + hostPart = urlInfo.first + pathPart = urlInfo.second + queryParams = urlInfo.third + + // 存储不带查询参数的基础URL(主机+路径) + baseUrlWithoutParams = hostPart + pathPart + + Log.d(TAG, "原始URL: $urlString") + Log.d(TAG, "主机部分: $hostPart") + Log.d(TAG, "路径部分: $pathPart") + Log.d(TAG, "查询参数: $queryParams") + Log.d(TAG, "完整基础URL: $baseUrlWithoutParams") + } + + // 创建SSE会话 - 直接使用原始URL,不添加/sse后缀 + session = urlString?.let { + // 完整的SSE连接URL(主机部分+原始路径+查询参数) + val sseConnectUrl = if (queryParams.isNotEmpty()) { + // 如果路径已包含查询参数,就不再添加 + if (pathPart.contains("?")) { + "$hostPart$pathPart" + } else { + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$hostPart$pathPart?$queryString" + } + } else { + "$hostPart$pathPart" + } + + Log.d(TAG, "SSE连接URL: $sseConnectUrl") + + client.sseSession( + urlString = sseConnectUrl, + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + } ?: client.sseSession( + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + + // 收集SSE事件 + collectEvents() + + // 等待endpoint就绪 + endpoint.await() + Log.d(TAG, "传输层启动完成,消息端点已就绪") + } + + /** + * 发送消息 + */ + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun send(message: JSONRPCMessage) { + if (!endpoint.isCompleted) { + Log.e(TAG, "发送失败: 未连接") + error("Not connected") + } + + try { + val messageEndpoint = endpoint.getCompleted() + Log.d(TAG, "发送消息到: $messageEndpoint") + + // 序列化消息 + val jsonString = json.encodeToString(message) + + val response = client.post(messageEndpoint) { + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(jsonString) + } + + if (!response.status.isSuccess()) { + val text = response.bodyAsText() + Log.e(TAG, "发送消息失败: HTTP ${response.status}, $text") + error("Error POSTing to endpoint (HTTP ${response.status}): $text") + } + } catch (e: Exception) { + Log.e(TAG, "发送消息异常: ${e.message}", e) + _onError(e) + throw e + } + } + + /** + * 关闭传输层 + */ + override suspend fun close() { + if (!initialized.get()) { + Log.e(TAG, "关闭失败: 传输层未初始化") + error("CustomSseClientTransport is not initialized!") + } + + session.cancel() + _onClose() + job?.cancelAndJoin() + Log.d(TAG, "传输层已关闭") + } + + /** + * 检查传输层是否已初始化 + */ + fun isInitialized(): Boolean { + return initialized.get() + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/.gitkeep b/local_plugins/open_ai_service/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift new file mode 100644 index 000000000..5df99e126 --- /dev/null +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift @@ -0,0 +1,531 @@ +import Foundation + +/// OpenAI服务异常 +public class OpenAIError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} + +/// 工具调用信息 +private class ToolCallInfo { + var id: String = "" + var name: String = "" + var arguments: String = "" + + var isValid: Bool { + return !id.isEmpty && !name.isEmpty + } +} + +/// OpenAI服务iOS原生实现 +public class OpenAIService { + private let TAG = "OpenAIService" + private var baseUrl = "https://api.openai.com/v1/chat/completions" + private var apiKey: String = "" + private var isInitialized = false + private var model: String = "doubao-1-5-lite-32k-250115" // 默认模型 + + // 用于存储注册的函数 + private var registeredFunctions: [[String: Any]] = [] + + // URL会话 + private let session: URLSession + + public init() { + // 创建URL会话配置 + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 30.0 + session = URLSession(configuration: config) + } + + /// 创建用户消息 + public func createUserMessage(content: String) -> [String: Any] { + return ["role": "user", "content": content] + } + + /// 创建助手消息 + public func createAssistantMessage(content: String) -> [String: Any] { + return ["role": "assistant", "content": content] + } + + /// 初始化OpenAI服务 + public func initialize(apiKey: String, baseUrl: String = "", model: String = "") -> Bool { + self.apiKey = apiKey + if !baseUrl.isEmpty { + self.baseUrl = baseUrl + } + if !model.isEmpty { + self.model = model + } + isInitialized = !apiKey.isEmpty + return isInitialized + } + + /// 注册函数 + public func registerFunction(name: String, description: String, parameters: [String: Any]) -> Bool { + do { + let function: [String: Any] = [ + "name": name, + "description": description, + "parameters": parameters + ] + + // 检查是否已存在相同名称的函数 + if let existingIndex = registeredFunctions.firstIndex(where: { ($0["name"] as? String) == name }) { + // 如果已存在,则替换 + registeredFunctions[existingIndex] = function + } else { + // 如果不存在,则添加 + registeredFunctions.append(function) + } + + return true + } catch { + return false + } + } + + /// 发送消息(非流式输出) + public func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { + guard isInitialized, !apiKey.isEmpty else { + throw OpenAIError("OpenAI服务未初始化") + } + + // 构建完整消息,添加系统提示 + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + fullMessages.append(contentsOf: messages) + + // 构建请求体 + var requestDict: [String: Any] = [ + "model": model, + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": false + ] + + // 如果有注册的函数,添加到请求中 + if !registeredFunctions.isEmpty { + var tools: [[String: Any]] = [] + for function in registeredFunctions { + let tool: [String: Any] = [ + "type": "function", + "function": function + ] + tools.append(tool) + } + requestDict["tools"] = tools + } + + // 将请求数据转换为JSON数据 + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestDict) else { + throw OpenAIError("无法序列化请求数据") + } + + // 创建URL请求 + guard let url = URL(string: baseUrl) else { + throw OpenAIError("无效的URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = jsonData + + // 创建信号量用于同步请求 + let semaphore = DispatchSemaphore(value: 0) + var responseResult: Result = .failure(OpenAIError("未收到响应")) + + // 执行请求 + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + responseResult = .failure(OpenAIError("请求失败: \(error.localizedDescription)")) + semaphore.signal() + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + responseResult = .failure(OpenAIError("无效的HTTP响应")) + semaphore.signal() + return + } + + guard httpResponse.statusCode == 200 else { + responseResult = .failure(OpenAIError("API调用失败: \(httpResponse.statusCode)")) + semaphore.signal() + return + } + + guard let data = data else { + responseResult = .failure(OpenAIError("响应数据为空")) + semaphore.signal() + return + } + + do { + // 解析JSON响应 + guard let jsonResponse = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + responseResult = .failure(OpenAIError("无法解析JSON响应")) + semaphore.signal() + return + } + + // 检查是否有函数调用 + if let choices = jsonResponse["choices"] as? [[String: Any]], !choices.isEmpty, + let choice = choices.first, + let message = choice["message"] as? [String: Any] { + + // 检查是否有工具调用 + if let toolCalls = message["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty, + let toolCall = toolCalls.first, + let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String, + let arguments = function["arguments"] as? String, + let id = toolCall["id"] as? String { + + let functionCallDict: [String: Any] = [ + "name": name, + "arguments": arguments, + "id": id + ] + + // 将函数调用转为JSON字符串 + if let functionCallData = try? JSONSerialization.data(withJSONObject: functionCallDict), + let functionCallString = String(data: functionCallData, encoding: .utf8) { + responseResult = .success(functionCallString) + semaphore.signal() + return + } + } + + // 如果没有工具调用,返回消息内容 + if let content = message["content"] as? String { + responseResult = .success(content) + semaphore.signal() + return + } + } + + responseResult = .failure(OpenAIError("无效的响应格式")) + semaphore.signal() + + } catch { + responseResult = .failure(OpenAIError("解析响应时出错: \(error.localizedDescription)")) + semaphore.signal() + } + } + + task.resume() + + // 等待响应完成 + _ = semaphore.wait(timeout: .distantFuture) + + // 返回结果或抛出错误 + switch responseResult { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + /// 发送消息(流式输出) + public func sendMessageStream( + messages: [[String: Any]], + systemPrompt: String, + callback: @escaping StreamCallback + ) { + guard isInitialized, !apiKey.isEmpty else { + callback.onError(OpenAIError("OpenAI服务未初始化")) + return + } + + // 构建请求体 + var requestBody: [String: Any] = [ + "model": model, + "temperature": 0.7, + "max_tokens": 2000, + "stream": true + ] + + // 构建完整消息数组,添加系统提示 + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + fullMessages.append(contentsOf: messages) + requestBody["messages"] = fullMessages + + // 添加工具列表 + if let toolMaps = mcpClient?.getToolMaps(), !toolMaps.isEmpty { + var tools: [[String: Any]] = [] + + for toolMap in toolMaps { + if let tool = toolMap as? [String: Any] { + tools.append(tool) + } + } + + if !tools.isEmpty { + requestBody["tools"] = tools + } + } else if !registeredFunctions.isEmpty { + var tools: [[String: Any]] = [] + + for function in registeredFunctions { + let tool: [String: Any] = [ + "type": "function", + "function": function + ] + tools.append(tool) + } + + if !tools.isEmpty { + requestBody["tools"] = tools + } + } + + // 转换为JSON数据 + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { + callback.onError(OpenAIError("无法序列化请求数据")) + return + } + + // 创建URL请求 + guard let url = URL(string: baseUrl) else { + callback.onError(OpenAIError("无效的URL")) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("text/event-stream", forHTTPHeaderField: "Accept") + request.httpBody = jsonData + + // 创建流式会话任务 + let delegate = SSEStreamDelegate(callback: callback) + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let task = session.dataTask(with: request) + task.resume() + } + + /// 发送函数调用结果 + public func sendFunctionCallResult( + messages: [[String: Any]], + systemPrompt: String, + functionCall: [String: Any], + functionResult: String, + callback: @escaping StreamCallback + ) { + do { + // 构建完整消息数组 + var fullMessages: [[String: Any]] = [ + // 添加系统提示 + ["role": "system", "content": systemPrompt] + ] + + // 添加用户消息 + fullMessages.append(contentsOf: messages) + + // 获取函数相关信息 + guard let name = functionCall["name"] as? String, + let arguments = functionCall["arguments"] as? String else { + callback.onError(OpenAIError("函数调用信息不完整")) + return + } + + let id = functionCall["id"] as? String ?? "call_\(Int(Date().timeIntervalSince1970 * 1000))" + + // 添加函数调用消息 + fullMessages.append([ + "role": "assistant", + "content": NSNull(), + "tool_calls": [ + [ + "id": id, + "type": "function", + "function": [ + "name": name, + "arguments": arguments + ] + ] + ] + ]) + + // 添加函数调用结果 + fullMessages.append([ + "role": "tool", + "content": functionResult, + "tool_call_id": id + ]) + + // 发送完整对话 + sendMessageStream(messages: fullMessages, systemPrompt: systemPrompt, callback: callback) + + } catch { + callback.onError(OpenAIError("发送函数调用结果失败: \(error.localizedDescription)")) + } + } + + /// SSE流委托实现 + private class SSEStreamDelegate: NSObject, URLSessionDataDelegate { + let callback: StreamCallback + private var buffer = Data() + private var finalToolCalls: [Int: ToolCallInfo] = [:] + + init(callback: @escaping StreamCallback) { + self.callback = callback + super.init() + } + + // 接收数据流 + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + buffer.append(data) + + // 处理可能包含多行的数据 + processBuffer() + } + + // 处理缓冲区数据 + private func processBuffer() { + // 按行分割 + while let newlineIndex = buffer.firstIndex(of: 10) { // 10是换行符的ASCII码 + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(0...newlineIndex) // 移除已处理的行,包括换行符 + + // 解析行数据 + if let line = String(data: lineData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { + processLine(line) + } + } + } + + // 处理单行数据 + private func processLine(_ line: String) { + guard !line.isEmpty else { return } + + if line.hasPrefix("data: ") { + let dataContent = line.dropFirst(6) + + // 处理[DONE]消息 + if dataContent == "[DONE]" { + let hasToolCalls = self.processToolCalls() + // 只有在没有工具调用时才认为对话真正完成 + if !hasToolCalls { + callback.onComplete() + } + return + } + + // 解析JSON数据 + do { + if let data = dataContent.data(using: .utf8), + let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + + // 处理消息内容 + if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty, + let choice = choices.first { + + if let delta = choice["delta"] as? [String: Any] { + // 处理普通文本内容 + if let content = delta["content"] as? String { + callback.onToken(content) + } + + // 处理工具调用(函数调用) + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for toolCall in toolCalls { + if let index = toolCall["index"] as? Int { + // 创建或获取现有的工具调用信息 + let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo() + + // 更新ID + if let id = toolCall["id"] as? String { + toolCallInfo.id = id + } + + // 更新函数信息 + if let function = toolCall["function"] as? [String: Any] { + if let name = function["name"] as? String { + toolCallInfo.name = name + } + + if let arguments = function["arguments"] as? String { + toolCallInfo.arguments += arguments + } + } + + finalToolCalls[index] = toolCallInfo + } + } + } + } + } + } + } catch { + NSLog("解析JSON出错: \(error.localizedDescription)") + // 忽略解析错误,继续处理其他行 + } + } + } + + // 处理工具调用 + private func processToolCalls() -> Bool { + if finalToolCalls.isEmpty { return false } + + // 只处理第一个工具调用 + guard let firstToolCall = finalToolCalls.values.first, firstToolCall.isValid else { return false } + + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": firstToolCall.name, + "arguments": firstToolCall.arguments, + "id": firstToolCall.id + ] + + // 回调 + callback.onFunctionCall(functionCall) + return true + } + + // 处理完成 + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + callback.onError(OpenAIError("请求失败: \(error.localizedDescription)")) + } + } + } + + /// 流式输出回调协议 + public typealias StreamCallback = (onToken: (String) -> Void, + onComplete: () -> Void, + onError: (Error) -> Void, + onFunctionCall: ([String: Any]) -> Void) + + /// 处理工具调用(函数调用)并回调 + private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) -> Bool { + if toolCalls.isEmpty { return false } + + // 只处理第一个工具调用 + guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return false } + + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": firstToolCall.name, + "arguments": firstToolCall.arguments, + "id": firstToolCall.id + ] + + // 回调 + callback.onFunctionCall(functionCall) + return true + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift new file mode 100644 index 000000000..d919e2584 --- /dev/null +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift @@ -0,0 +1,215 @@ +import Flutter +import UIKit + +public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + // OpenAI服务实例 + private let openAIService = OpenAIService() + + // 事件接收器 + private var eventSink: FlutterEventSink? + + // 注册插件 + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.open_ai_service/methods", binaryMessenger: registrar.messenger()) + let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.open_ai_service/events", binaryMessenger: registrar.messenger()) + + let instance = OpenAIServicePlugin() + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(instance) + } + + // 处理方法调用 + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + if let args = call.arguments as? [String: Any], + let apiKey = args["apiKey"] as? String { + let baseUrl = args["baseUrl"] as? String ?? "" + let model = args["model"] as? String ?? "" + let initialized = openAIService.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model) + result(initialized) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "初始化参数无效", details: nil)) + } + + case "registerFunction": + if let args = call.arguments as? [String: Any], + let name = args["name"] as? String, + let description = args["description"] as? String, + let parameters = args["parameters"] as? [String: Any] { + + let registered = openAIService.registerFunction(name: name, description: description, parameters: parameters) + result(registered) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "函数注册参数无效", details: nil)) + } + + case "sendMessage": + guard let args = call.arguments as? [String: Any], + let messagesRaw = args["messages"] as? [[String: Any]], + let systemPrompt = args["systemPrompt"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil)) + return + } + + // 在后台线程执行 + DispatchQueue.global(qos: .userInitiated).async { + do { + let response = try self.openAIService.sendMessage(messages: messagesRaw, systemPrompt: systemPrompt) + // 在主线程返回结果 + DispatchQueue.main.async { + result(response) + } + } catch { + // 在主线程返回错误 + DispatchQueue.main.async { + result(FlutterError(code: "OPENAI_ERROR", message: error.localizedDescription, details: nil)) + } + } + } + + case "sendMessageStream": + guard let args = call.arguments as? [String: Any], + let messagesRaw = args["messages"] as? [[String: Any]], + let systemPrompt = args["systemPrompt"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil)) + return + } + + // 检查事件接收器 + guard let eventSink = self.eventSink else { + result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil)) + return + } + + // 在后台线程执行 + DispatchQueue.global(qos: .userInitiated).async { + let callback: OpenAIService.StreamCallback = ( + onToken: { token in + // 发送token事件 + DispatchQueue.main.async { + eventSink(["type": "token", "content": token]) + } + }, + onComplete: { + // 发送完成事件 + DispatchQueue.main.async { + eventSink(["type": "complete"]) + } + }, + onError: { error in + // 发送错误事件 + DispatchQueue.main.async { + eventSink(["type": "error", "content": error.localizedDescription]) + } + }, + onFunctionCall: { functionCall in + // 发送函数调用事件 + DispatchQueue.main.async { + eventSink(["type": "functionCall", "content": functionCall]) + } + } + ) + + self.openAIService.sendMessageStream(messages: messagesRaw, systemPrompt: systemPrompt, callback: callback) + + // 请求已开始 + DispatchQueue.main.async { + result(true) + } + } + + case "sendFunctionCallResult": + guard let args = call.arguments as? [String: Any], + let messagesRaw = args["messages"] as? [[String: Any]], + let systemPrompt = args["systemPrompt"] as? String, + let functionCallRaw = args["functionCall"] as? [String: Any], + let functionResult = args["functionResult"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "发送函数调用结果参数无效", details: nil)) + return + } + + // 检查事件接收器 + guard let eventSink = self.eventSink else { + result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil)) + return + } + + // 在后台线程执行 + DispatchQueue.global(qos: .userInitiated).async { + let callback: OpenAIService.StreamCallback = ( + onToken: { token in + // 发送token事件 + DispatchQueue.main.async { + eventSink(["type": "token", "content": token]) + } + }, + onComplete: { + // 发送完成事件 + DispatchQueue.main.async { + eventSink(["type": "complete"]) + } + }, + onError: { error in + // 发送错误事件 + DispatchQueue.main.async { + eventSink(["type": "error", "content": error.localizedDescription]) + } + }, + onFunctionCall: { functionCall in + // 发送函数调用事件 + DispatchQueue.main.async { + eventSink(["type": "functionCall", "content": functionCall]) + } + } + ) + + self.openAIService.sendFunctionCallResult( + messages: messagesRaw, + systemPrompt: systemPrompt, + functionCall: functionCallRaw, + functionResult: functionResult, + callback: callback + ) + + // 请求已开始 + DispatchQueue.main.async { + result(true) + } + } + + case "createUserMessage": + if let args = call.arguments as? [String: Any], + let content = args["content"] as? String { + let message = openAIService.createUserMessage(content: content) + result(message) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "创建用户消息参数无效", details: nil)) + } + + case "createAssistantMessage": + if let args = call.arguments as? [String: Any], + let content = args["content"] as? String { + let message = openAIService.createAssistantMessage(content: content) + result(message) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "创建助手消息参数无效", details: nil)) + } + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - FlutterStreamHandler + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/open_ai_service.podspec b/local_plugins/open_ai_service/ios/open_ai_service.podspec new file mode 100644 index 000000000..d06d07fed --- /dev/null +++ b/local_plugins/open_ai_service/ios/open_ai_service.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint open_ai_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'open_ai_service' + s.version = '0.0.1' + s.summary = 'OpenAI Service plugin' + s.description = <<-DESC +A Flutter plugin for OpenAI services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/open_ai_service/lib/open_ai_service.dart b/local_plugins/open_ai_service/lib/open_ai_service.dart new file mode 100644 index 000000000..022d53dbd --- /dev/null +++ b/local_plugins/open_ai_service/lib/open_ai_service.dart @@ -0,0 +1,375 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// OpenAI服务异常 +class OpenAIException implements Exception { + final String message; + + OpenAIException(this.message); + + @override + String toString() => 'OpenAIException: $message'; +} + +/// OpenAI服务事件类型 +enum OpenAIEventType { + token, + complete, + error, + functionCall, +} + +/// OpenAI服务事件 +class OpenAIEvent { + final OpenAIEventType type; + final dynamic content; + final dynamic meta; + + OpenAIEvent({required this.type, this.content, this.meta}); + + factory OpenAIEvent.fromMap(Map map) { + final typeStr = map['type'] as String; + final content = map['content']; + + return OpenAIEvent( + type: _typeFromString(typeStr), + content: content, + ); + } + + static OpenAIEventType _typeFromString(String typeStr) { + switch (typeStr) { + case 'token': + return OpenAIEventType.token; + case 'complete': + return OpenAIEventType.complete; + case 'error': + return OpenAIEventType.error; + case 'functionCall': + return OpenAIEventType.functionCall; + default: + throw ArgumentError('未知的事件类型: $typeStr'); + } + } +} + +/// OpenAI服务插件 +class OpenAIService { + static const MethodChannel _channel = + MethodChannel('com.yunqiinnovation.open_ai_service/methods'); + static const EventChannel _eventChannel = + EventChannel('com.yunqiinnovation.open_ai_service/events'); + + /// 事件流控制器 + StreamController? _eventStreamController; + + /// 事件流 + Stream? _eventStream; + + /// 获取事件流 + Stream get eventStream { + if (_eventStream == null) { + _eventStreamController = StreamController.broadcast(); + _eventStream = _eventStreamController!.stream; + + // 监听原生事件 + _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is Map) { + final eventMap = Map.from(event); + final openAIEvent = OpenAIEvent.fromMap(eventMap); + _eventStreamController!.add(openAIEvent); + } + }, + onError: (error) { + _eventStreamController!.addError(OpenAIException('事件流错误: $error')); + }, + ); + } + + return _eventStream!; + } + + /// 初始化OpenAI服务 + /// + /// [apiKey] OpenAI API密钥 + /// [baseUrl] 可选,自定义API基础URL + /// [model] 可选,自定义使用的模型 + /// [mcpServer] 可选,MCP服务器地址配置 + Future initialize({ + required String apiKey, + String baseUrl = '', + String model = '', + String mcpServer = '', + }) async { + try { + final result = await _channel.invokeMethod( + 'initialize', + { + 'apiKey': apiKey, + 'baseUrl': baseUrl, + 'model': model, + 'mcpServer': mcpServer, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('初始化失败: $e'); + } + } + + /// 注册函数 + /// + /// [name] 函数名称 + /// [description] 函数描述 + /// [parameters] 函数参数 + Future registerFunction({ + required String name, + required String description, + required Map parameters, + }) async { + try { + final result = await _channel.invokeMethod( + 'registerFunction', + { + 'name': name, + 'description': description, + 'parameters': parameters, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('注册函数失败: $e'); + } + } + + /// 创建用户消息 + /// + /// [content] 消息内容 + Future> createUserMessage(String content) async { + try { + final result = await _channel.invokeMethod>( + 'createUserMessage', + {'content': content}, + ); + + if (result == null) { + throw OpenAIException('创建用户消息失败: 结果为空'); + } + + return Map.from(result); + } catch (e) { + throw OpenAIException('创建用户消息失败: $e'); + } + } + + /// 创建助手消息 + /// + /// [content] 消息内容 + Future> createAssistantMessage(String content) async { + try { + final result = await _channel.invokeMethod>( + 'createAssistantMessage', + {'content': content}, + ); + + if (result == null) { + throw OpenAIException('创建助手消息失败: 结果为空'); + } + + return Map.from(result); + } catch (e) { + throw OpenAIException('创建助手消息失败: $e'); + } + } + + /// 发送消息(非流式输出) + /// + /// [messages] 消息列表 + Future sendMessage({ + required List> messages, + }) async { + try { + final result = await _channel.invokeMethod( + 'sendMessage', + { + 'messages': messages, + }, + ); + + if (result == null) { + throw OpenAIException('发送消息失败: 结果为空'); + } + + return result; + } catch (e) { + throw OpenAIException('发送消息失败: $e'); + } + } + + /// 发送消息(流式输出) + /// + /// [messages] 消息列表 + /// + /// 返回一个布尔值,表示请求是否已开始 + Future sendMessageStream({ + required List> messages, + }) async { + try { + final result = await _channel.invokeMethod( + 'sendMessageStream', + { + 'messages': messages, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('发送流式消息失败: $e'); + } + } + + /// 发送函数调用结果 + /// + /// [messages] 消息列表 + /// [functionCall] 函数调用信息 + /// [functionResult] 函数调用结果 + /// + /// 返回一个布尔值,表示请求是否已开始 + Future sendFunctionCallResult({ + required List> messages, + required Map functionCall, + required String functionResult, + }) async { + try { + final result = await _channel.invokeMethod( + 'sendFunctionCallResult', + { + 'messages': messages, + 'functionCall': functionCall, + 'functionResult': functionResult, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('发送函数调用结果失败: $e'); + } + } + + /// 从JSON字符串解析函数调用 + Map parseFunctionCall(String functionCallJson) { + try { + return json.decode(functionCallJson) as Map; + } catch (e) { + throw OpenAIException('解析函数调用失败: $e'); + } + } + + /// 初始化MCP客户端 + /// + /// [serverUrl] MCP服务器WebSocket地址 + /// + /// 返回一个布尔值,表示请求是否已开始初始化(实际初始化过程是异步的) + Future initializeMcpClient(String serverUrl) async { + try { + final result = await _channel.invokeMethod( + 'initializeMcpClient', + {'serverUrl': serverUrl}, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('初始化MCP客户端失败: $e'); + } + } + + /// 检查MCP客户端是否已初始化 + Future isMcpInitialized() async { + try { + final result = await _channel.invokeMethod('isMcpInitialized'); + return result ?? false; + } catch (e) { + throw OpenAIException('检查MCP客户端状态失败: $e'); + } + } + + /// 关闭MCP客户端 + Future closeMcpClient() async { + try { + final result = await _channel.invokeMethod('closeMcpClient'); + return result ?? false; + } catch (e) { + throw OpenAIException('关闭MCP客户端失败: $e'); + } + } + + /// 处理MCP工具调用 + /// + /// [functionCall] 函数调用信息,必须包含name和arguments字段 + /// + /// 返回工具调用结果 + Future handleMcpToolCall(Map functionCall) async { + try { + final result = await _channel.invokeMethod( + 'handleMcpToolCall', + {'functionCall': jsonEncode(functionCall)}, + ); + + return result ?? "处理MCP工具调用失败: 结果为空"; + } catch (e) { + throw OpenAIException('处理MCP工具调用失败: $e'); + } + } + + /// 处理函数调用(自动处理本地函数和MCP工具) + /// + /// 这是一个辅助方法,用于简化函数调用处理流程: + /// 1. MCP工具会被自动处理,不会通过回调返回 + /// 2. 只有本地函数才需要手动处理 + /// 3. 提供统一的处理接口 + /// + /// 参数: + /// [functionCall] 函数调用信息 + /// [messages] 消息上下文 + /// [localFunctionHandler] 本地函数处理器,接收函数名和参数,返回处理结果 + /// + /// 返回值: + /// 布尔值表示是否成功处理(已将结果发送回OpenAI) + Future handleFunctionCall({ + required Map functionCall, + required List> messages, + required Future Function( + String name, Map arguments) + localFunctionHandler, + }) async { + final name = functionCall['name'] as String; + final argumentsJson = functionCall['arguments'] as String; + + // 解析参数 + Map arguments; + try { + arguments = json.decode(argumentsJson) as Map; + } catch (e) { + arguments = {}; + } + + try { + // 调用本地函数处理器 + final result = await localFunctionHandler(name, arguments); + + // 发送结果回OpenAI + return sendFunctionCallResult( + messages: messages, + functionCall: functionCall, + functionResult: result, + ); + } catch (e) { + throw OpenAIException('处理函数调用失败: $e'); + } + } +} diff --git a/local_plugins/open_ai_service/pubspec.yaml b/local_plugins/open_ai_service/pubspec.yaml new file mode 100644 index 000000000..8e00ed3ea --- /dev/null +++ b/local_plugins/open_ai_service/pubspec.yaml @@ -0,0 +1,26 @@ +name: open_ai_service +description: 原生OpenAI服务插件,提供与OpenAI API的交互功能,支持流式输出和函数调用。 +version: 0.0.1 +homepage: https://github.com/yunqiinnovation/deep_voice + +environment: + sdk: ">=2.17.0 <4.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.open_ai_service + pluginClass: OpenAIServicePlugin + ios: + pluginClass: OpenAIServicePlugin \ No newline at end of file diff --git a/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/bug_report.md b/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..425690576 --- /dev/null +++ b/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,62 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'bug' +assignees: '' + +--- + +**!! Attention !!** +FILL IN ALL SECTIONS OF THIS TEMPLATE OR YOUR ISSUE WILL BE CLOSED WITHOUT INVESTIGATION +This is a flutter package that wraps the native [iOS](https://github.com/spotify/ios-sdk) and [Android](https://github.com/spotify/android-sdk) Spotify "remote" SDKs as well as the [Spotify Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/) for web. Since it wraps the native SDKs it has the same features and limitations. Please use the search functionality in the native SDK repos to find corresponding issues before opening an issue here. If your issue is deriving from the native SDKs you should open an issue there and not in this repo. + +**Is this a how to question?** +Yes/No +If Yes, please use Stack Overflow instead. + +**Is this a native Spotify SDK issue?** +Yes/No/I don't know +If you answered "Yes" then your issue belongs to the native SDK repo and not here. +If you answered "I don't know" then you should visit the corresponding native SDK repo and make sure that this is not a native SDK issue. + +**Have you searched for existing issues?** +Yes/No +If you answered "No" you should that first before opening a new issue. + +**Are you using a Free or Premium Spotify account?** +Free/Premium + +**Are you aware of the limitations of Free Spotify accounts?** +Yes/No +If you answered "No" and you are using a Free account then you make sure that this is not behavior that intentionally limited by the native SDK. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Describe the bug** +A clear and concise description of what happened instead. + +**Steps to Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Spotify_sdk Flutter package version** +Check you `pubspeck.lock` file to verify the exact version [e.g 2.1, commit ID] + +**Target Platform, Version & Device** + - OS & Version: [e.g. iOS 8.1, Android 9, Web] + - Browser & Version (if applicable) [e.g. Safari 15, Chrome 22] + - Device: [e.g. iPhone 6, Pixel 5, PC] + +**Development OS** + - OS: [e.g. Mac OS, Windows, Linux] + +**Additional context** +Add any other information about the problem here. diff --git a/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/config.yml b/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/feature_request.md b/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..36014cde5 --- /dev/null +++ b/local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/local_plugins/spotify_sdk/.github/actions/publish_flutter_package/action.yaml b/local_plugins/spotify_sdk/.github/actions/publish_flutter_package/action.yaml new file mode 100644 index 000000000..91b7cf8c0 --- /dev/null +++ b/local_plugins/spotify_sdk/.github/actions/publish_flutter_package/action.yaml @@ -0,0 +1,41 @@ +name: Publish Flutter package to pub.dev +description: Publish your Flutter package to pub.dev + +inputs: + working-directory: + description: directory with-in the repository where the package is located (if not in the repository root) + required: false + +runs: + using: "composite" + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v4 + - name: 🐦 Setup Flutter + uses: subosito/flutter-action@v2 + + - name: 🪪 Get Id Token + uses: actions/github-script@v6 + with: + script: | + let pub_token = await core.getIDToken('https://pub.dev') + core.exportVariable('PUB_TOKEN', pub_token) + + - name: 📢 Authenticate + shell: ${{ inputs.shell }} + run: flutter pub pub token add https://pub.dev --env-var PUB_TOKEN + + - name: 📦 Install dependencies + shell: ${{ inputs.shell }} + run: flutter pub get + working-directory: ${{ inputs.working-directory }} + + - name: 🌵 Dry Run + shell: ${{ inputs.shell }} + run: flutter pub publish --dry-run + working-directory: ${{ inputs.working-directory }} + + - name: 📢 Publish + shell: ${{ inputs.shell }} + run: flutter pub publish -f + working-directory: ${{ inputs.working-directory }} diff --git a/local_plugins/spotify_sdk/.github/dependabot.yml b/local_plugins/spotify_sdk/.github/dependabot.yml new file mode 100644 index 000000000..2268b44a6 --- /dev/null +++ b/local_plugins/spotify_sdk/.github/dependabot.yml @@ -0,0 +1,42 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pub" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + commit-message: + prefix: "chore" + include: "scope" + groups: + spotify_sdk: + patterns: + - "*" + + - package-ecosystem: "pub" + directory: "/example" # Location of package manifests in the example directory + schedule: + interval: "weekly" + commit-message: + prefix: "chore" + include: "scope" + groups: + spotify_sdk_example: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "chore" + include: "scope" + groups: + github_actions: + patterns: + - "*" diff --git a/local_plugins/spotify_sdk/.github/scripts/verify_pub_score.sh b/local_plugins/spotify_sdk/.github/scripts/verify_pub_score.sh new file mode 100755 index 000000000..00fab26a7 --- /dev/null +++ b/local_plugins/spotify_sdk/.github/scripts/verify_pub_score.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Runs `pana . --no-warning` and verifies that the package score +# is greater or equal to the desired score. By default the desired score is +# a perfect score but it can be overridden by passing the desired score as an argument. +# +# Ensure the package has a score of at least a 100 +# `./verify_pub_score.sh 100` +# +# Ensure the package has a perfect score +# `./verify_pub_score.sh` + +PANA=$(pana . --no-warning); PANA_SCORE=$(echo $PANA | sed -n "s/.*Points: \([0-9]*\)\/\([0-9]*\)./\1\/\2/p") +echo "score: $PANA_SCORE" +IFS='/'; read -a SCORE_ARR <<< "$PANA_SCORE"; SCORE=SCORE_ARR[0]; TOTAL=SCORE_ARR[1] +if [ -z "$1" ]; then MINIMUM_SCORE=TOTAL; else MINIMUM_SCORE=$1; fi +if (( $SCORE < $MINIMUM_SCORE )); then echo "minimum score $MINIMUM_SCORE was not met!"; exit 1; fi \ No newline at end of file diff --git a/local_plugins/spotify_sdk/.github/workflows/pull_request.yml b/local_plugins/spotify_sdk/.github/workflows/pull_request.yml new file mode 100644 index 000000000..da53b39eb --- /dev/null +++ b/local_plugins/spotify_sdk/.github/workflows/pull_request.yml @@ -0,0 +1,38 @@ +name: spotify_sdk +on: pull_request + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + - name: Install Dependencies + run: flutter packages get + - name: Format + run: dart format --set-exit-if-changed lib test example + - name: Analyze + run: flutter analyze lib test example + - name: Publish dry run + run: flutter pub publish --dry-run + - name: Build web version + run: | + cd example + flutter build web + + pana: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + + - name: Install Dependencies + run: | + flutter packages get + flutter pub global activate pana + sudo apt-get install webp + + - name: Verify Pub Score + run: ./.github/scripts/verify_pub_score.sh diff --git a/local_plugins/spotify_sdk/.github/workflows/release.yml b/local_plugins/spotify_sdk/.github/workflows/release.yml new file mode 100644 index 000000000..474883130 --- /dev/null +++ b/local_plugins/spotify_sdk/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Publish to pub.dev +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+*" # tag-pattern on pub.dev: '' + +jobs: + pana: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + + - name: Install Dependencies + run: | + flutter packages get + flutter pub global activate pana + sudo apt-get install webp + + - name: Verify Pub Score + run: ./.github/scripts/verify_pub_score.sh + + publish: + needs: pana + environment: 'pub.dev' + permissions: + id-token: write # Required for authentication using OIDC + runs-on: ubuntu-latest + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/publish_flutter_package diff --git a/local_plugins/spotify_sdk/.gitignore b/local_plugins/spotify_sdk/.gitignore new file mode 100644 index 000000000..00f30fcf9 --- /dev/null +++ b/local_plugins/spotify_sdk/.gitignore @@ -0,0 +1,50 @@ +.DS_Store +.atom/ +.idea/ +.vscode/ + +.packages +.pub/ +.dart_tool/ +pubspec.lock +flutter_export_environment.sh + +examples/all_plugins/pubspec.yaml + +Podfile +Podfile.lock +Pods/ +.symlinks/ +**/Flutter/App.framework/ +**/Flutter/Flutter.framework/ +**/Flutter/Generated.xcconfig +**/Flutter/flutter_assets/ +ServiceDefinitions.json +xcuserdata/ +*.xcworkspace +**/DerivedData/ + +local.properties +keystore.properties +.gradle/ +gradlew +gradlew.bat +gradle-wrapper.jar +.flutter-plugins-dependencies +*.iml + +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m +GeneratedPluginRegistrant.java +build/ +.flutter-plugins + +.project +.classpath +.settings +*.sqlite +/.vs/spotify_sdk/v16/.suo + +SpotifyiOS.framework +**/doc/api/ +/ios/ios-sdk/ \ No newline at end of file diff --git a/local_plugins/spotify_sdk/.metadata b/local_plugins/spotify_sdk/.metadata new file mode 100644 index 000000000..ef377714d --- /dev/null +++ b/local_plugins/spotify_sdk/.metadata @@ -0,0 +1,10 @@ +# 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: 856a90e67c9284124d44d2be6c785bacd3a1c772 + channel: beta + +project_type: plugin diff --git a/local_plugins/spotify_sdk/CHANGELOG.md b/local_plugins/spotify_sdk/CHANGELOG.md new file mode 100644 index 000000000..df7c3d8a5 --- /dev/null +++ b/local_plugins/spotify_sdk/CHANGELOG.md @@ -0,0 +1,184 @@ +## 3.0.2 +* chore: update deprications (#224) + +## 3.0.1 +* **BREAKING**: `isSpotifyAppActive` is removed from iOS and Android (#222) and therefore removed from the sdk +* chore: Bump native iOS SDK to 3.0.0 (#222) + +## 3.0.0 +* All changes from dev builds +* chore: Bump native iOS SDK to 1.2.4 (#208) +* chore: Update Spotify iOS SDK to v2.1.7 (#216) +* chore: Update android versions (#219) + +## 3.0.0-dev.3 +* Fix: prevent multiple iOS initializations (#203) +* Chore: Update libraries (#202) +* Feat: Automatic setup of the android integration of the spotify_sdk (#204) + +## 3.0.0-dev.2 +* Feat: add set podcastPlaybackSpeed and switchToLocalDevice for android (#160) + +## 3.0.0-dev.1 +* **BREAKING**:feat: update spotify.android:auth from 1.2.6 to 2.1.0 and spotify.app.remote from 0.7.2 to 0.8.0 + In the app/build.gradle add the following to the default config for auth to work as described [here](https://github.com/spotify/android-auth#integrating-the-library-into-your-project) + ```groovy + defaultConfig { + manifestPlaceholders = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth"] + ... + } + ``` +* Update android target sdk to 34 + +## 2.3.1 +* Fix: null album when getting advertisement on android (#179) +* Fix: queue endpoint for web (#167) +* Update license to apache-2.0 + +## 2.3.0 +* iOS, Android and Web + * getAuthenticationToken is deprecated in favor of getAccessToken +* Android: + * `spotify-auth` SDK is now retrieved via Maven Central instead of being sourced from an AAR file + * Steps to remove the `spotify-auth` SDK: + * android/settings.gradle -> remove `':spotify-auth'` + * android/spotify-auth/build.gradle -> remove file + * android/spotify-auth/spotify-auth-release-x.x.x.aar -> remove file + +## 2.2.0 +* iOS and Android + * adds SkipToIndex and getCapabilities + * adds isSpotifyAppActive + * adds getLibraryState on iOS + * Switch to native Spotify iOS XCFramework + * fixes android json mapping error on release builds + * fixes skipPrevious on iOS +* web + * adds use accessToken which implies reusing _spotifyToken from getAuthenticationToken + * adds optional tokenSwapURL and tokenRefreshURL for Authorization Code (without PKCE) + * adds support for track relinking + * fixes browser autoplay error +* updates libraries +* updates documentation + +## 2.1.0 +* BREAKING: setShuffle now does not expect a named argument +* fixes accessToken being ignored in connectToSpotify() +* exposes spotifyURI on connectToSpotify() method +* fixes the web implementation not using the authentication scopes supplied by the user +* fix a crash when calling subscribeToConnectionStatus +* fix a bug where the webplayer would not dispose +* add null safety to example app + +## 2.0.0 +* BREAKING: opt into null safety +* upgrade Dart SDK constraints to >=2.12.0-0 <3.0.0 + +## 1.0.2 +* fixes image dimension exception on getImage + +## 1.0.1 +* improves error reporting on iOS +* supports adding additional scopes for iOS +* offers option to start radio while connecting to Spotify +* updates packages + +## 1.0.0 +* __adds support for iOS__ 🎉 (thanks [fotiDim](https://github.com/fotiDim)) +* adapts to breaking changes in the spotify android sdk (thanks [itsMatoosh](https://github.com/itsMatoosh)) +* implements PKCE auth flow for the web implementation (thanks [itsMatoosh](https://github.com/itsMatoosh)) +* adds the `setShuffle()` and `setRepeatMode()` APIs for Android (thanks [Joran-Dob](https://github.com/Joran-Dob)) +* renames logout to disconnect +* adds `accessToken` as an optional parameter to `connectToSpotifyRemote()` (only supported on iOS) +* some minor bug fixing + +## 0.5.0 +* adds support for web (thanks [itsMatoosh](https://github.com/itsMatoosh)) +* adds custom scopes for the web api (thanks [arnav-sh](https://github.com/arnav-sh)) +* adds logout functionality for android +* moved from [pedantic](https://pub.dev/packages/pedantic) to [lint](https://pub.dev/packages/lint) for static analyses +* some minor bug fixing + +## 0.3.4 + +* adds handling of unexpected disconnects from Spotify via subscribeConnectionStatus()-Stream(thanks [itsMatoosh](https://github.com/itsMatoosh)) +* adds usage of .env file for the example project +* fixes some minor error message issues + +## 0.3.3 + +* adds getImage to get an Image from any spotifyURI (thanks [eddwhite](https://github.com/eddwhite)) +* fixes some minor issues +* raised dart-sdk version to 2.7.0 + +## 0.3.2 + +* fixes compatibility with spotify-auth dependency above version 1.2.0 (thanks [itsMatoosh](https://github.com/itsMatoosh)) + * spotify introduced some breaking changes: Rename classes from AuthenticationClassName to AuthorizationClassName + +## 0.3.1 + +* fixes wrong links and incorrect docs + +## 0.3.0 + +* android user api implementation finished + * remove from library + * subscribe to user status + * subscribe to capabilities + * get librarystate +* updated package references + +## 0.2.0 + +* android player api implementation subscriptions finished + * subscribe to playerContext and playerState now possible +* added more instructions for android +* code refactoring +* extended the example +* extended the documentation + +## 0.1.0 + +* android player api implementation finished +* error handling finished +* setup instructions for android finished +* naming for modules finished + +## 0.0.4 + +* added instructions for android +* fixed naming for modules + +## 0.0.3 + +* added the following implementations for android: + * getCrossfadeState + * getPlayerState +* general refactoring of the native android implementation +* adds documentation to all methods +* adds json_annotation: ^3.0.0, build_runner: ^1.0.0, json_serializable: ^3.2.0 to make use of some json serializing functionality for the crossfadeState and PlayerState + +## 0.0.2 + +* added the following implementations for android: + * resume + * skip next + * skip previous + * seek to + * seek to relative +* splitted remote authorization and token retrieval in two seperate functions +* implemented the example project +* added logger package for prettier logs + +## 0.0.1 + +* Added latests spotify-app-remote (v7.0.0) and spotify-auth (v1.1.0) from +* finished android native implementation for + * authorization with token response + * play + * pause + * queue + * toggleShuffle + * toggleRepeat + * addToLibrary diff --git a/local_plugins/spotify_sdk/CODE_OF_CONDUCT.md b/local_plugins/spotify_sdk/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..bb829fedf --- /dev/null +++ b/local_plugins/spotify_sdk/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at tobi@minimalme.de. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/local_plugins/spotify_sdk/CONTRIBUTING.md b/local_plugins/spotify_sdk/CONTRIBUTING.md new file mode 100644 index 000000000..db7ce8670 --- /dev/null +++ b/local_plugins/spotify_sdk/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# How you can contribute to spotify_sdk + +Hi nice to see you here. 🙌🎉 + +Thank you for taking the time to contribute to this package ! 👍 + +This document should be a set of guidelines that can help you to create a meaningful +pull request or issue. Don't see this as any kind of rules, this is a living document, +so if you have an idea how to optimize this propose changes in a pull request. + +## How do I propose a change? + +Changes to the public API should be done via an issue. So we can discuss the proposed +changes before you put any work into them. + +If you are fixing a bug, you can just submit a pull request. We do recommend filing an issue +as well to get an overview what it is that you are fixing. +This is helpful in case we don’t accept that specific fix but want to keep +track of the issue. + +## What do I do before creating a pull request + +1. Fork the repository and branch out of `master`, prefixing your branch's name with `feature/`, `bug/` or `task/` to indicate the scope of the PR. +1. Install all dependencies (`flutter packages get` or `pub get`) +1. Ensure you have a meaningful PR name using the [imperative mood](https://chris.beams.io/posts/git-commit/#imperative) as all your commits will be squashed upon merge and the PR's name will be used as the merge commit's message. +1. If the PR can be broken down into multiple meaningful PRs please do so so that it is easier to review. +1. If you’ve fixed a bug or added code that should be tested, add tests! +1. If you've changed the public API, make sure to update/add documentation (for now that is the [Readme](README.md)) +1. If you've made breaking changes give us a heads up in the pull request. Try to provide a compatibility path for the deprecated APIs and if necessary provide migration instructions in the [Readme](README.md). +1. Format your code (`dartfmt -w .`) +1. Analyze your code (`flutter analyze`) +1. Create the Pull Request +1. Verify that all status checks are passing + +While the prerequisites above must be satisfied prior to having your +pull request reviewed, the reviewer(s) may ask you to complete additional +design work, tests, or other changes before your pull request can be ultimately +accepted. + +## Getting in Touch + +If you want to just ask a question or get feedback on an idea you can post it +on [Slack](https://join.slack.com/t/spotifysdk/shared_invite/zt-gibgpkf9-o2ZEJBMPqXNTvqqPONYUQA). + +## License + +By contributing to spotify_sdk, you agree that your contributions will be licensed +under its [MIT license](LICENSE). diff --git a/local_plugins/spotify_sdk/LICENSE b/local_plugins/spotify_sdk/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/local_plugins/spotify_sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/local_plugins/spotify_sdk/README.md b/local_plugins/spotify_sdk/README.md new file mode 100644 index 000000000..d9a48385e --- /dev/null +++ b/local_plugins/spotify_sdk/README.md @@ -0,0 +1,257 @@ +# spotify_sdk + +

+build +build +build +

+ +--- + +## Description + +This is a flutter package that wraps the native [iOS](https://github.com/spotify/ios-sdk) and [Android](https://github.com/spotify/android-sdk) Spotify "remote" SDKs as well as the [Spotify Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/) for web. Since it wraps the native SDKs it has the same features and limitations. + +## Setup + +### Android + +This package is using both the spotify-app-remote sdk and spotify-auth library. The auth library is needed to get the access token to work with the web api. + +From the [Spotify Android SDK Quick Start](https://developer.spotify.com/documentation/android/quick-start/). You need two things: + +1. Register your app in the [spotify developer portal](https://developer.spotify.com/dashboard/). You also need to create a sha-1 fingerprint and add this and your package name to the app settings on the dashboard as well as a redirect url. +2. Follow the steps below for either [Option A: Auto setup](#option-a-auto-setup) or [Option B: Manual setup](#option-b-manual-setup-instructions-for-android-studio-42). + +#### Option A: Auto setup + +Use the provided setup script to automatically download the latest version of the spotify-app-remote sdk from GitHub and setup the gradle files inside your android project. Run the following command in the root folder of your flutter project. + +```bash +dart run spotify_sdk:android_setup +``` + +Use the `--help` flag to see all available options. + +#### Option B: Manual setup, instructions for Android Studio 4.2+ + +Download the current [Spotify Android SDK](https://github.com/spotify/android-sdk/releases). Here you need the `spotify-app-remote-\*.aar``. + +After you are all setup you need to add the SDKs `\*.aar`` file to your Android Project as Module. See the [Spotify Android SDK Quick Start](https://developer.spotify.com/documentation/android/quick-start/) for detailed information. + +Since Android Studio 4.2 you need to manually perform these steps in order to add .jar/.aar files: + +1. Open the android folder of your flutter project as an Android Studio project +2. In the android root folder create a single folder for `spotify-app-remote`, place the corresponding aar file and create an empty build.gradle file, like on the screenshot below: + ![image](https://user-images.githubusercontent.com/42183561/125422846-24e03bf0-ec7f-409f-b382-0ef2d0213d08.png) + +3. Content of the `spotify-app-remote/build.gradle` file: + +```groovy +configurations.maybeCreate("default") +artifacts.add("default", file('spotify-app-remote-release-x.x.x.aar')) +``` + +4. In the android root folder find `settings.gradle` file, open it and add the following line at the top of the file: + +```groovy +include ':spotify-app-remote' +``` + +5. In the app/build.gradle add the following to the default config + +```groovy +defaultConfig { + manifestPlaceholders = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth"] + ... + } +``` + +### iOS + +Register your app in the [spotify developer portal](https://developer.spotify.com/dashboard/). You also need to register your Bundle ID as well as a Redirect URI. + +Follow the instructions in the section `Setup the iOS SDK` of [Spotify iOS SDK Quick Start](https://developer.spotify.com/documentation/ios/quick-start/). + +### Web + +1. Register your app in the [spotify developer portal](https://developer.spotify.com/dashboard/). You need to provide a redirect URL which points to a dedicated page on a website you own. + +2. Paste the following onto the webpage, which you linked to in your redirect URL. +```html + + + + Authenticating Spotify + + +

Please wait while we authenticate Spotify...

+ + + +``` + +3. Optionally add this to your Flutter app web/index.html to avoid a Javascript `TypeError: r.__extends is not a function` error in development mode. + +```html + + +``` + +[You need Spotify Premium to access the Web SDK.](https://developer.spotify.com/documentation/web-playback-sdk/quick-start/) + +## Usage + +To start using this package first import it in your Dart file. + +```dart +import 'package:spotify_sdk/spotify_sdk.dart'; +``` + +To connect to the Spotify app you can call connectToSpotifyRemote(...) or getAccessToken(...). In both of these methods you need the client id, which you will find in the Spotify developer dashboard and the redirect url you set there for that specific client. + +```dart +await SpotifySdk.connectToSpotifyRemote(clientId: "", redirectUrl: "") +``` + +Subscribe to `PlayerState` or `PlayerContext` streams only after connecting successfully + +```dart +SpotifySdk.subscribePlayerState(); +SpotifySdk.subscribePlayerContext(); +``` + +If you want to use the web api as well you have to use this method to get the access token. +You can specify multiple scopes by separating them with a comma "," as shown below. For more information on scopes you can refer to [Spotify Authorization Scopes Guide](https://developer.spotify.com/documentation/general/guides/authorization/scopes/) + +```dart +final accessToken = await SpotifySdk.getAccessToken(clientId: "", redirectUrl: "", scope: "app-remote-control,user-modify-playback-state,playlist-read-private"); +``` + +On Web you can use the token that you get from `getAccessToken(...)` and then pass it to `connectToSpotifyRemote(...)`. This will avoid having to send user through two Spotify OAuth prompts. You should not persist this token, nor supply a different token, because the refresh token is only set interally by `getAccessToken` or `connectToSpotifyRemote`. + +On iOS you can store the token that you get from `getAccessToken(...)` and then pass it to `connectToSpotifyRemote(...)` during the next session. This will avoid having to switch to the Spotify app for establishing the connection. This library does not handle storing the token. It is up to you to persist it wherever you see fit. Keep in mind that this feature is currently quite buggy in the native iOS SDK and has many side effects like random disconnections. Proceed with caution. + +On iOS Spotify starts playing music when attempting connection. This is a default behavior and there is no official way to prevent this with the currently supported authentication flows. You have the option to pass a Spotify URI upon connection or set it to a blank string to play the last played song. There is an undocumented workaround if you don't want music to start playing which is to pass an invalid Spotify URI instead. This is not officially supported by the Spotify SDK or this library and it can fail or stop working at any time! + +Have a look [in the example](example/lib/main.dart) for detailed insights on how you can use this package. + +### Token Swap + +You can optionally specify "token swap" URLs to manage tokens with a backend service that protects your OAuth client secret. For more information refer to the [Spotify Token Swap and Refresh Guide](https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/) + +```dart +SpotifySdkPlugin.tokenSwapURL = 'https://example.com/api/spotify/token'; +SpotifySdkPlugin.tokenRefreshURL = 'https://example.com/api/spotify/refresh'; +```` + +On web, this package will perform an Authorization Code (without PKCE) flow, then exchange the code and refresh the token with a backend service you run at the URLs provided. + +Token Swap is for now "web only". While the iOS SDK also supports the "token swap", this flow is not yet supported. + +### Api + +#### Connecting/Authenticating + +| Function | Description| Android | iOS | Web | +|---|---|---|---|---| +| connectToSpotifyRemote | Connects the App to Spotify | ✔ | ✔ | ✔ | +| getAccessToken | Gets the Access Token that you can use to work with the [Web Api](https://developer.spotify.com/documentation/web-api/) | ✔ | ✔ | ✔ | +| disconnect | Disconnects the app connection | ✔ | ✔ | ✔ | +| subscribeConnectionStatus | Subscribes to the current player state. | ✔ | ✔ | 🚧 | + +#### Player Api + +The playerApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/PlayerApi.html). + +| Function | Description | Android | iOS | Web | +|-------------------------|---|--|---|---| +| getCrossfadeState | Gets the current crossfade state | ✔ | ✔ | ❌ | +| getPlayerState | Gets the current player state |✔ | ✔ | ✔ | +| pause | Pauses the current track |✔ | ✔ | ✔ | +| play | Plays the given spotifyUri |✔ | ✔ | ✔ | +| playWithStreamType | Play the given Spotify uri with specific behaviour for that streamtype | 🚧 | 🚧 | 🚧 | +| queue | Queues given spotifyUri |✔ | ✔ | ✔ | +| resume | Resumes the current track |✔ | ✔ | ✔ | +| seekTo | Seeks the current track to the given position in milliseconds | ✔ | ✔ | 🚧 | +| seekToRelativePosition | Adds to the current position of the track the given milliseconds | ✔ | ❌ | 🚧 | +| setPodcastPlaybackSpeed | Set playback speed for Podcast | ✔ | 🚧 | 🚧 | +| setRepeatMode | Set the repeat mode | ✔ | ✔ | ✔ | +| setShuffle | Set the shuffle mode | ✔ | ✔ | ✔ | +| skipNext | Skips to next track | ✔ | ✔ | ✔ | +| skipPrevious | Skips to previous track |✔ | ✔ | ✔ | +| skipToIndex | Skips to track at specified index in album or playlist |✔ | ✔ | 🚧 | +| subscribePlayerContext | Subscribes to the current player context | ✔ | ✔ | ✔ | +| subscribePlayerState | Subscribes to the current player state | ✔ | ✔ | ✔ | +| toggleRepeat | Cycles through the repeat modes | ✔ | ✔ | ❌ | +| toggleShuffle | Cycles through the shuffle modes | ✔ | ❌ | ❌ | + +On Web, an automatic call to play may not work due to media activation policies which send an error: "Authentication Error: Browser prevented autoplay due to lack of interaction". This error is ignored by the SDK so you can still present a button for the user to click to `play` or `resume` to start playback. See the [Web SDK Troubleshooting guide](https://developer.spotify.com/documentation/web-playback-sdk/reference/#troubleshooting) for more details. + +#### Images Api + +The imagesApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/ImagesApi.html). + +| Function | Description| Android | iOS | Web | +|---|---|---|---|---| +| getImage | Get the image from the given spotifyUri | ✔ | ✔ | 🚧 | + +#### User Api + +The userApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/UserApi.html). + +| Function | Description| Android | iOS | Web | +|---|---|---|---|---| +| addToLibrary | Adds the given spotifyUri to the users library | ✔ | ✔ | 🚧 | +| getCapabilities | Gets the current users capabilities | ✔ | ✔ | 🚧 | +| getLibraryState | Gets the current library state | ✔ | ✔ | 🚧 | +| removeFromLibrary | Removes the given spotifyUri to the users library | ✔ | ✔ | 🚧 | +| subscribeCapabilities | Subscribes to the current users capabilities | ✔ | 🚧 | 🚧 | +| subscribeUserStatus | Subscribes to the current users status | ✔ | 🚧 | 🚧 | + +#### Connect Api + +The connectApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/ConnectApi.html). + +| Function | Description | Android | iOS | Web | +|----------------------------|--------------------------------------------|---|---|---| +| connectDecreaseVolume | Decrease volume by a step size determined | 🚧 | 🚧 | 🚧 | +| connectIncreaseVolume | Increase volume by a step size determined | 🚧 | 🚧 | 🚧 | +| connectSetVolume | Set a volume on the currently active device | 🚧 | 🚧 | 🚧 | +| connectSwitchToLocalDevice | Switch to play music on this (local) device | ✔ | 🚧 | 🚧 | +| subscribeToVolumeState | Subscribe to volume state | 🚧 | 🚧 | 🚧 | + +#### Content Api + +The contentApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/ContentApi.html). + +| Function | Description| Android | iOS | Web | +|---|---|---|---|---| +| getChildrenOfItem | tbd | 🚧 | 🚧 | 🚧 | +| getRecommendedContentItems | tbd | 🚧 | 🚧 | 🚧 | +| playContentItem | tbd | 🚧 | 🚧 | 🚧 | + +## Migration + +### spotify-auth: moving from locally sourced aar to maven central +`spotify-auth` SDK is now retrieved via Maven Central instead of being sourced from an AAR file +* Steps to remove the locally sourced `spotify-auth` SDK: + * android/settings.gradle -> remove `':spotify-auth'` + * android/spotify-auth/build.gradle -> remove file + * android/spotify-auth/spotify-auth-release-x.x.x.aar -> remove file + +## Official Spotify Docs + +- [Auth](https://spotify.github.io/android-sdk/auth-lib/docs/index.html) +- [App Remote](https://spotify.github.io/android-sdk/app-remote-lib/docs/index.html) +- [Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/) + + diff --git a/local_plugins/spotify_sdk/analysis_options.yaml b/local_plugins/spotify_sdk/analysis_options.yaml new file mode 100644 index 000000000..1cec1d72f --- /dev/null +++ b/local_plugins/spotify_sdk/analysis_options.yaml @@ -0,0 +1,12 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + # Ignore generated files + - "**/*.g.dart" + - "lib/generated/**" + errors: + close_sinks: ignore +linter: + rules: + unsafe_html: false diff --git a/local_plugins/spotify_sdk/android/.gitignore b/local_plugins/spotify_sdk/android/.gitignore new file mode 100644 index 000000000..c6cbe562a --- /dev/null +++ b/local_plugins/spotify_sdk/android/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/local_plugins/spotify_sdk/android/build.gradle b/local_plugins/spotify_sdk/android/build.gradle new file mode 100644 index 000000000..c11324b59 --- /dev/null +++ b/local_plugins/spotify_sdk/android/build.gradle @@ -0,0 +1,74 @@ +group 'de.minimalme.spotify_sdk' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.22' + repositories { + google() + mavenCentral() + maven { url "https://jitpack.io" } + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + maven { url "https://jitpack.io" } + } + +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'de.minimalme.spotify_sdk' + } + + compileSdk 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 21 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + lintOptions { + disable 'InvalidPackage' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + // -- spotify + implementation "com.spotify.android:auth:2.1.0" + implementation(name: 'spotify-app-remote-release-0.8.0', ext: 'aar') + implementation 'com.google.code.gson:gson:2.10.1' + // -- events + implementation "com.github.stuhlmeier:kotlin-events:v2.0" +} + +repositories { + flatDir { + dirs 'libs' + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/gradle.properties b/local_plugins/spotify_sdk/android/gradle.properties new file mode 100644 index 000000000..94adc3a3f --- /dev/null +++ b/local_plugins/spotify_sdk/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/local_plugins/spotify_sdk/android/gradle/wrapper/gradle-wrapper.properties b/local_plugins/spotify_sdk/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..dedd5d1e6 --- /dev/null +++ b/local_plugins/spotify_sdk/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/local_plugins/spotify_sdk/android/libs/spotify-app-remote-release-0.8.0.aar b/local_plugins/spotify_sdk/android/libs/spotify-app-remote-release-0.8.0.aar new file mode 100644 index 000000000..ec49aad33 Binary files /dev/null and b/local_plugins/spotify_sdk/android/libs/spotify-app-remote-release-0.8.0.aar differ diff --git a/local_plugins/spotify_sdk/android/settings.gradle b/local_plugins/spotify_sdk/android/settings.gradle new file mode 100644 index 000000000..eabe3661d --- /dev/null +++ b/local_plugins/spotify_sdk/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'spotify_sdk' \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/AndroidManifest.xml b/local_plugins/spotify_sdk/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..156eeeae3 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/BaseSpotifyApi.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/BaseSpotifyApi.kt new file mode 100644 index 000000000..0c5624d9b --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/BaseSpotifyApi.kt @@ -0,0 +1,13 @@ +package de.minimalme.spotify_sdk + +import com.spotify.android.appremote.api.SpotifyAppRemote +import io.flutter.plugin.common.MethodChannel + +open class BaseSpotifyApi(internal val spotifyAppRemote: SpotifyAppRemote?, internal val result: MethodChannel.Result) { + + private val errorAppRemoteNull = "spotifyAppRemoteNull" + + internal fun spotifyRemoteAppNotSetError() { + result.error(errorAppRemoteNull, "spotifyAppRemote is null", "") + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyConnectApi.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyConnectApi.kt new file mode 100644 index 000000000..615f890c5 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyConnectApi.kt @@ -0,0 +1,25 @@ +package de.minimalme.spotify_sdk + +import com.spotify.android.appremote.api.SpotifyAppRemote +import com.spotify.protocol.types.Image.Dimension +import com.spotify.protocol.types.ImageUri +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream +import android.graphics.Bitmap + +class SpotifyConnectApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { + + private val errorConnectSwitchToLocalDevice = "errorConnectSwitchToLocalDevice" + + private val connectApi = spotifyAppRemote?.connectApi + + fun switchToLocalDevice() { + if (connectApi != null) { + connectApi.connectSwitchToLocalDevice() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorConnectSwitchToLocalDevice, "error when switching to local device", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyImagesApi.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyImagesApi.kt new file mode 100644 index 000000000..e7439758f --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyImagesApi.kt @@ -0,0 +1,35 @@ +package de.minimalme.spotify_sdk + +import com.spotify.android.appremote.api.SpotifyAppRemote +import com.spotify.protocol.types.Image.Dimension +import com.spotify.protocol.types.ImageUri +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream +import android.graphics.Bitmap + +class SpotifyImagesApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { + + private val errorGetImage = "errorGetImage" + private val errorImageUri = "errorImageUri" + private val errorImageDimension = "errorImageDimensionUri" + + private val imagesApi = spotifyAppRemote?.imagesApi + + fun getImage(imageUri: String?, dimension: Int?) { + if (imagesApi != null && imageUri != null && dimension != null) { + imagesApi.getImage(ImageUri(imageUri), Dimension.values().first{it.value == dimension}) + .setResultCallback { bitmap -> + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + result.success(stream.toByteArray()) + } + .setErrorCallback { throwable -> result.error(errorGetImage, "error when getting the image", throwable.toString()) } + } else if (imageUri == null) { + result.error(errorImageUri, "imageUri has invalid format or is not set", "") + } else if (dimension == null) { + result.error(errorImageDimension, "imageDimension has invalid format or is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyPlayerApi.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyPlayerApi.kt new file mode 100644 index 000000000..f3378b153 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyPlayerApi.kt @@ -0,0 +1,210 @@ +package de.minimalme.spotify_sdk + +import com.google.gson.Gson +import com.spotify.android.appremote.api.SpotifyAppRemote +import com.spotify.protocol.types.PlaybackSpeed +import io.flutter.plugin.common.MethodChannel + +class SpotifyPlayerApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { + + + private val errorCrossfadeState = "crossfadeStateError" + private val errorPlayerState = "PlayerStateError" + private val errorQue = "queueError" + private val errorPlay = "playError" + private val errorPause = "pauseError" + private val errorResume = "resumeError" + private val errorSkipNext = "skipNextError" + private val errorSkipPrevious = "skipPreviousError" + private val errorSeekTo = "seekToError" + private val errorSkipToIndex = "skipToIndexError" + private val errorPodcastPlaybackSpeed = "podcastPlaybackSpeedError" + private val errorToggleShuffle = "toggleShuffleError" + private val errorToggleRepeat = "toggleRepeatError" + + private val playerApi = spotifyAppRemote?.playerApi + + internal fun getCrossfadeState() { + if (playerApi != null) { + playerApi.crossfadeState + .setResultCallback { crossfadeState -> + result.success(Gson().toJson(crossfadeState)) + } + .setErrorCallback { throwable -> result.error(errorCrossfadeState, "error when getting the current state of crossfade setting", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun getPlayerState() { + if (playerApi != null) { + playerApi.playerState + .setResultCallback { playerState -> + result.success(Gson().toJson(playerState)) + + } + .setErrorCallback { throwable -> result.error(errorPlayerState, "error when getting the current state of the player", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun queue(spotifyUri: String?) { + if (playerApi != null && !spotifyUri.isNullOrBlank()) { + playerApi.queue(spotifyUri) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorQue, "error when adding uri: $spotifyUri to queue", throwable.toString()) } + } else if (spotifyUri.isNullOrBlank()) { + result.error(errorQue, "spotifyUri has invalid format or is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun play(spotifyUri: String?) { + if (playerApi != null && !spotifyUri.isNullOrBlank()) { + playerApi.play(spotifyUri) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorPlay, "error when playing uri: $spotifyUri", throwable.toString()) } + } else if (spotifyUri.isNullOrBlank()) { + result.error(errorPlay, "spotifyUri has invalid format or is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun pause() { + if (playerApi != null) { + playerApi.pause() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorPause, "error when pausing", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun resume() { + if (playerApi != null) { + playerApi.resume() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorResume, "error when resuming", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun seekTo(milliseconds: Int?) { + val castedMilliseconds = milliseconds?.toLong() + if (playerApi != null && castedMilliseconds != null) { + playerApi.seekTo(castedMilliseconds) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorResume, "error when seeking to: $castedMilliseconds", throwable.toString()) } + } else if (castedMilliseconds == null) { + result.error(errorSeekTo, "positionMS is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun seekToRelativePosition(milliseconds: Int?) { + val castedMilliseconds = milliseconds?.toLong() + if (playerApi != null && castedMilliseconds != null) { + playerApi.seekToRelativePosition(castedMilliseconds) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorResume, "error when seeking relative to: $castedMilliseconds", throwable.toString()) } + } else if (castedMilliseconds == null) { + result.error(errorSeekTo, "milliseconds is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun setPodcastPlaybackSpeed(podcastPlaybackSpeedValue: Int?) { + if (playerApi != null && podcastPlaybackSpeedValue != null) { + + val podcastPlaybackSpeed = PlaybackSpeed.PodcastPlaybackSpeed.values().firstOrNull{ it.value == podcastPlaybackSpeedValue } + + playerApi.setPodcastPlaybackSpeed(podcastPlaybackSpeed) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorPodcastPlaybackSpeed, "error when setting the podcastPlaybackSpeed to: $podcastPlaybackSpeed", throwable.toString()) } + } else if (podcastPlaybackSpeedValue == null) { + result.error(errorPodcastPlaybackSpeed, "podcastPlaybackSpeedValue is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun skipNext() { + if (playerApi != null) { + playerApi.skipNext() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorSkipNext, "error when skipping next", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun skipPrevious() { + if (playerApi != null) { + playerApi.skipPrevious() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorSkipPrevious, "error when skipping previous", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun skipToIndex(uri: String?, index: Int?) { + if (playerApi != null && !uri.isNullOrBlank() && index != null) { + playerApi.skipToIndex(uri, index) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorSkipToIndex, "error when skipping to index", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun toggleShuffle() { + if (playerApi != null) { + playerApi.toggleShuffle() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorToggleShuffle, "error when toggle shuffle", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun setShuffle(shuffle: Boolean?) { + if (playerApi != null && shuffle != null) { + playerApi.setShuffle(shuffle) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorToggleRepeat, "error when toggle shuffle", throwable.toString()) } + } else if (shuffle == null) { + result.error(errorQue, "shuffle has invalid format or is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun toggleRepeat() { + if (playerApi != null) { + playerApi.toggleRepeat() + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorToggleRepeat, "error when toggle repeat", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + internal fun setRepeatMode(repeatMode: Int?) { + if (playerApi != null && repeatMode != null) { + playerApi.setRepeat(repeatMode) + .setResultCallback { result.success(true) } + .setErrorCallback { throwable -> result.error(errorToggleRepeat, "error when toggle repeat", throwable.toString()) } + } else if (repeatMode == null) { + result.error(errorQue, "repeatMode has invalid format or is not set", "") + } else { + spotifyRemoteAppNotSetError() + } + } +} diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt new file mode 100644 index 000000000..262837b33 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt @@ -0,0 +1,381 @@ +package de.minimalme.spotify_sdk + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.util.Log +import com.spotify.android.appremote.api.ConnectionParams +import com.spotify.android.appremote.api.Connector.ConnectionListener +import com.spotify.android.appremote.api.SpotifyAppRemote +import com.spotify.android.appremote.api.error.* +import com.spotify.sdk.android.auth.AuthorizationClient +import com.spotify.sdk.android.auth.AuthorizationRequest +import com.spotify.sdk.android.auth.AuthorizationResponse +import de.minimalme.spotify_sdk.subscriptions.* +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.EventChannel +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 io.flutter.plugin.common.PluginRegistry +import kotlinx.event.SetEvent +import kotlinx.event.event + +class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener { + + // application context + private var applicationContext : Context? = null + private var applicationActivity : Activity? = null + + // method channel + private lateinit var methodChannel : MethodChannel + private val channelName = "spotify_sdk" + private val loggingTag = "spotify_sdk" + + // event channels + private var playerContextChannel : EventChannel? = null + private var playerStateChannel : EventChannel? = null + private var capabilitiesChannel : EventChannel? = null + private var userStatusChannel : EventChannel? = null + private var connectionStatusChannel : EventChannel? = null + + private val playerContextSubscription = "player_context_subscription" + private val playerStateSubscription = "player_state_subscription" + private val capabilitiesSubscription = "capabilities_subscription" + private val userStatusSubscription = "user_status_subscription" + private val connectionStatusSubscription = "connection_status_subscription" + + //connecting + private val methodConnectToSpotify = "connectToSpotify" + private val methodGetAccessToken = "getAccessToken" + private val methodDisconnectFromSpotify = "disconnectFromSpotify" + + // connectApi + private val methodSwitchToLocalDevice = "switchToLocalDevice" + + //playerApi + private val methodGetCrossfadeState = "getCrossfadeState" + private val methodGetPlayerState = "getPlayerState" + private val methodPlay = "play" + private val methodPause = "pause" + private val methodQueueTrack = "queueTrack" + private val methodResume = "resume" + private val methodSeekToRelativePosition = "seekToRelativePosition" + private val methodSetPodcastPlaybackSpeed = "setPodcastPlaybackSpeed" + private val methodSkipNext = "skipNext" + private val methodSkipPrevious = "skipPrevious" + private val methodSkipToIndex = "skipToIndex" + private val methodSeekTo = "seekTo" + private val methodToggleRepeat = "toggleRepeat" + private val methodToggleShuffle = "toggleShuffle" + private val methodSetShuffle = "setShuffle" + private val methodSetRepeatMode = "setRepeatMode" + + //userApi + private val methodAddToLibrary = "addToLibrary" + private val methodRemoveFromLibrary = "removeFromLibrary" + private val methodGetCapabilities = "getCapabilities" + private val methodGetLibraryState = "getLibraryState" + + //imagesApi + private val methodGetImage = "getImage" + + private val paramClientId = "clientId" + private val paramRedirectUrl = "redirectUrl" + private val paramScope = "scope" + private val paramSpotifyUri = "spotifyUri" + private val paramImageUri = "imageUri" + private val paramImageDimension = "imageDimension" + private val paramPositionedMilliseconds = "positionedMilliseconds" + private val paramRelativeMilliseconds = "relativeMilliseconds" + private val paramPodcastPlaybackSpeed = "podcastPlaybackSpeed" + private val paramTrackIndex = "trackIndex" + private val paramRepeatMode = "repeatMode" + private val paramShuffle = "shuffle" + + private val errorConnecting = "errorConnecting" + private val errorDisconnecting = "errorDisconnecting" + private val errorConnection = "errorConnection" + private val errorAuthenticationToken = "authenticationTokenError" + + private var connStatusEventChannel: SetEvent = event() + + private val requestCodeAuthentication = 1337 + + private var pendingOperation: PendingOperation? = null + private var spotifyAppRemote: SpotifyAppRemote? = null + private var spotifyPlayerApi: SpotifyPlayerApi? = null + private var spotifyConnectApi: SpotifyConnectApi? = null + private var spotifyUserApi: SpotifyUserApi? = null + private var spotifyImagesApi: SpotifyImagesApi? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + this.applicationContext = binding.applicationContext + + methodChannel = MethodChannel(binding.binaryMessenger, channelName) + methodChannel.setMethodCallHandler(this) + + playerContextChannel = EventChannel(binding.binaryMessenger, playerContextSubscription) + playerStateChannel = EventChannel(binding.binaryMessenger, playerStateSubscription) + capabilitiesChannel = EventChannel(binding.binaryMessenger, capabilitiesSubscription) + userStatusChannel = EventChannel(binding.binaryMessenger, userStatusSubscription) + connectionStatusChannel = EventChannel(binding.binaryMessenger, connectionStatusSubscription) + + connectionStatusChannel?.setStreamHandler(ConnectionStatusChannel(connStatusEventChannel)) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + + applicationContext = null + + methodChannel.setMethodCallHandler(null) + + playerContextChannel?.setStreamHandler(null) + playerStateChannel?.setStreamHandler(null) + capabilitiesChannel?.setStreamHandler(null) + userStatusChannel?.setStreamHandler(null) + connectionStatusChannel?.setStreamHandler(null) + + playerContextChannel = null + playerStateChannel = null + capabilitiesChannel = null + userStatusChannel = null + connectionStatusChannel = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + binding.addActivityResultListener(this) + applicationActivity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + applicationActivity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + applicationActivity = binding.activity + } + + override fun onDetachedFromActivity() { + applicationActivity = null + } + + override fun onMethodCall(call: MethodCall, result: Result) { + + if (spotifyAppRemote != null) { + spotifyPlayerApi = SpotifyPlayerApi(spotifyAppRemote, result) + spotifyUserApi = SpotifyUserApi(spotifyAppRemote, result) + spotifyImagesApi = SpotifyImagesApi(spotifyAppRemote, result) + spotifyConnectApi = SpotifyConnectApi(spotifyAppRemote, result) + } + + when (call.method) { + //connecting to spotify + methodConnectToSpotify -> connectToSpotify(call.argument(paramClientId), call.argument(paramRedirectUrl), result) + methodGetAccessToken -> getAccessToken(call.argument(paramClientId), call.argument(paramRedirectUrl), call.argument(paramScope), result) + methodDisconnectFromSpotify -> disconnectFromSpotify(result) + //connectApi calls + methodSwitchToLocalDevice -> spotifyConnectApi?.switchToLocalDevice() + //playerApi calls + methodGetCrossfadeState -> spotifyPlayerApi?.getCrossfadeState() + methodGetPlayerState -> spotifyPlayerApi?.getPlayerState() + methodPlay -> spotifyPlayerApi?.play(call.argument(paramSpotifyUri)) + methodPause -> spotifyPlayerApi?.pause() + methodQueueTrack -> spotifyPlayerApi?.queue(call.argument(paramSpotifyUri)) + methodResume -> spotifyPlayerApi?.resume() + methodSeekTo -> spotifyPlayerApi?.seekTo(call.argument(paramPositionedMilliseconds)) + methodSeekToRelativePosition -> spotifyPlayerApi?.seekToRelativePosition(call.argument(paramRelativeMilliseconds)) + methodSetPodcastPlaybackSpeed -> spotifyPlayerApi?.setPodcastPlaybackSpeed(call.argument(paramPodcastPlaybackSpeed)) + methodSkipNext -> spotifyPlayerApi?.skipNext() + methodSkipPrevious -> spotifyPlayerApi?.skipPrevious() + methodSkipToIndex -> spotifyPlayerApi?.skipToIndex(call.argument(paramSpotifyUri), call.argument(paramTrackIndex)) + methodToggleShuffle -> spotifyPlayerApi?.toggleShuffle() + methodSetShuffle -> spotifyPlayerApi?.setShuffle(call.argument(paramShuffle)) + methodToggleRepeat -> spotifyPlayerApi?.toggleRepeat() + methodSetRepeatMode -> spotifyPlayerApi?.setRepeatMode(call.argument(paramRepeatMode)) + //userApi calls + methodAddToLibrary -> spotifyUserApi?.addToUserLibrary(call.argument(paramSpotifyUri)) + methodRemoveFromLibrary -> spotifyUserApi?.removeFromUserLibrary(call.argument(paramSpotifyUri)) + methodGetCapabilities -> spotifyUserApi?.getCapabilities() + methodGetLibraryState -> spotifyUserApi?.getLibraryState(call.argument(paramSpotifyUri)) + //imageApi calls + methodGetImage -> spotifyImagesApi?.getImage(call.argument(paramImageUri), call.argument(paramImageDimension)) + // method call is not implemented yet + else -> result.notImplemented() + } + } + + //-- Method implementations + private fun connectToSpotify(clientId: String?, redirectUrl: String?, result: Result) { + + if (clientId.isNullOrBlank() || redirectUrl.isNullOrBlank()) { + result.error(errorConnecting, "client id or redirectUrl are not set or have invalid format", "") + } else { + val connectionParams = ConnectionParams.Builder(clientId) + .setRedirectUri(redirectUrl) + .showAuthView(true) + .build() + SpotifyAppRemote.disconnect(spotifyAppRemote) + var initiallyConnected = false + SpotifyAppRemote.connect(applicationContext, connectionParams, + object : ConnectionListener { + override fun onConnected(spotifyAppRemoteValue: SpotifyAppRemote) { + spotifyAppRemote = spotifyAppRemoteValue + + playerContextChannel?.setStreamHandler(PlayerContextChannel(spotifyAppRemote!!.playerApi)) + Log.i(loggingTag, "Set stream handler for PlayerContextChannel") + playerStateChannel?.setStreamHandler(PlayerStateChannel(spotifyAppRemote!!.playerApi)) + Log.i(loggingTag, "Set stream handler for PlayerStateChannel") + capabilitiesChannel?.setStreamHandler(CapabilitiesChannel(spotifyAppRemote!!.userApi)) + Log.i(loggingTag, "Set stream handler for CapabilitiesChannel") + userStatusChannel?.setStreamHandler(UserStatusChannel(spotifyAppRemote!!.userApi)) + Log.i(loggingTag, "Set stream handler for UserStatusChannel") + initiallyConnected = true + + Log.i(loggingTag, "App Remote successfully connected") + // emit connection established event + connStatusEventChannel(ConnectionStatusChannel.ConnectionEvent(true, "Successfully connected to Spotify.", null, null)) + // method success + result.success(true) + } + + override fun onFailure(throwable: Throwable) { + val errorDetails = throwable.toString() + // determine the error + val errorMessage: String + val errorCode: String + var connected = false + when (throwable) { + is SpotifyDisconnectedException, is SpotifyConnectionTerminatedException -> { + // The Spotify app was/is disconnected by the Spotify app. + // This indicates typically that the Spotify app was closed by the user or for other reasons. + // You need to reconnect to continue using Spotify App Remote. + errorMessage = "The Spotify app was/is disconnected by the Spotify app.Reconnect necessary" + errorCode = "SpotifyDisconnectedException" + } + is CouldNotFindSpotifyApp -> { + errorMessage = "The Spotify app is not installed on the device" + errorCode = "CouldNotFindSpotifyApp" + } + is AuthenticationFailedException -> { + errorMessage = "Partner app failed to authenticate with Spotify. Check client credentials and make sure your app is registered correctly at developer.spotify.com" + errorCode = "AuthenticationFailedException" + } + is UserNotAuthorizedException -> { + errorMessage = "Indicates the user did not authorize this client of App Remote to use Spotify on the users behalf." + errorCode = "UserNotAuthorizedException" + } + is UnsupportedFeatureVersionException -> { + errorMessage = "Spotify app can't support requested features. User should update Spotify app." + errorCode = "UnsupportedFeatureVersionException" + connected = true + } + is OfflineModeException -> { + errorMessage = "Spotify user has set their Spotify app to be in offline mode" + errorCode = "OfflineModeException" + connected = true + } + is NotLoggedInException -> { + errorMessage = "User has logged out from Spotify." + errorCode = "NotLoggedInException" + } + is SpotifyRemoteServiceException -> { + errorMessage = "Encapsulates possible SecurityException and IllegalStateException errors." + errorCode = "SpotifyRemoteServiceException" + } + else -> { + errorMessage = "Something went wrong connecting spotify remote" + errorCode = errorConnection + } + } + Log.e(loggingTag, errorMessage) + // notify plugin + if (initiallyConnected) { + // emit connection error event + connStatusEventChannel(ConnectionStatusChannel.ConnectionEvent(connected, errorMessage, errorCode, errorDetails)) + } else { + // throw exception as the connect method + result.error(errorCode, errorMessage, errorDetails) + } + } + }) + } + } + + private fun getAccessToken(clientId: String?, redirectUrl: String?, scope: String?, result: Result) { + if (applicationActivity == null) { + throw IllegalStateException("getAccessToken needs a foreground activity") + } + + if (clientId.isNullOrBlank() || redirectUrl.isNullOrBlank()) { + result.error(errorConnecting, "client id or redirectUrl are not set or have invalid format", "") + } else { + //Convert String? scope to Array. Delimiter set as comma "," + val scopeArray = scope?.split(",")?.toTypedArray() + methodConnectToSpotify.checkAndSetPendingOperation(result) + + val builder = AuthorizationRequest.Builder(clientId, AuthorizationResponse.Type.TOKEN, redirectUrl) + builder.setScopes(scopeArray) + val request = builder.build() + + AuthorizationClient.openLoginActivity(applicationActivity, requestCodeAuthentication, request) + } + } + + private fun disconnectFromSpotify(result: Result) { + if (spotifyAppRemote != null && spotifyAppRemote!!.isConnected) { + SpotifyAppRemote.disconnect(spotifyAppRemote) + + // emit connection terminated event + connStatusEventChannel(ConnectionStatusChannel.ConnectionEvent(false, "Successfully disconnected from Spotify.", null, null)) + // method success + result.success(true) + } else if (!spotifyAppRemote!!.isConnected) { + result.error(errorDisconnecting, "could not disconnect spotify remote", "you are not connected, no need to disconnect") + } else { + result.error(errorDisconnecting, "could not disconnect spotify remote", "spotifyAppRemote is not set") + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (pendingOperation == null) { + return false + } + return when (requestCode) { + requestCodeAuthentication -> { + authFlow(resultCode, data) + return true + } + else -> false + } + } + + private fun authFlow(resultCode: Int, data: Intent?) { + + val response: AuthorizationResponse = AuthorizationClient.getResponse(resultCode, data) + val result = pendingOperation!!.result + pendingOperation = null + + when (response.type) { + AuthorizationResponse.Type.TOKEN -> { + result.success(response.accessToken) + } + AuthorizationResponse.Type.ERROR -> result.error(errorAuthenticationToken, "Authentication went wrong", response.error) + else -> result.notImplemented() + } + } + + private fun String.checkAndSetPendingOperation(result: Result) { + + check(pendingOperation == null) + { + "Concurrent operations detected: " + pendingOperation?.method.toString() + ", " + this + } + pendingOperation = PendingOperation(this, result) + } +} + +private class PendingOperation internal constructor(val method: String, val result: Result) diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyUserApi.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyUserApi.kt new file mode 100644 index 000000000..4ccb4cf51 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyUserApi.kt @@ -0,0 +1,59 @@ +package de.minimalme.spotify_sdk + +import com.google.gson.Gson +import com.spotify.android.appremote.api.SpotifyAppRemote +import io.flutter.plugin.common.MethodChannel + +class SpotifyUserApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { + + private val errorAddToLibrary = "addToLibraryError" + private val errorRemoveFromLibrary = "removeFromLibraryError" + private val errorGettingCapabilities = "getCapabilitiesError" + private val errorGettingLibraryState = "getLibraryStateError" + + private val userApi = spotifyAppRemote?.userApi + + fun addToUserLibrary(spotifyUri: String?) { + if (userApi != null && !spotifyUri.isNullOrBlank()) { + userApi.addToLibrary(spotifyUri) + .setResultCallback {result.success(true)} + .setErrorCallback { throwable -> result.error(errorAddToLibrary, "error when adding uri to user library", throwable.toString()) } + } else if (spotifyUri.isNullOrBlank()) { + result.error(errorAddToLibrary, "spotifyUri has invalid format", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + fun removeFromUserLibrary(spotifyUri: String?){ + if (userApi != null && !spotifyUri.isNullOrBlank()) { + userApi.removeFromLibrary(spotifyUri) + .setResultCallback {result.success(true)} + .setErrorCallback { throwable -> result.error(errorRemoveFromLibrary, "error when removing uri from user library", throwable.toString()) } + } else if (spotifyUri.isNullOrBlank()) { + result.error(errorRemoveFromLibrary, "spotifyUri has invalid format", "") + } else { + spotifyRemoteAppNotSetError() + } + } + + fun getCapabilities(){ + if (userApi != null) { + userApi.capabilities + .setResultCallback {capabilities -> result.success(Gson().toJson(capabilities))} + .setErrorCallback { throwable -> result.error(errorGettingCapabilities, "error when getting capabilities", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } + + fun getLibraryState(spotifyUri: String?){ + if (userApi != null && !spotifyUri.isNullOrBlank()) { + userApi.getLibraryState(spotifyUri) + .setResultCallback {libraryState -> result.success(Gson().toJson(libraryState))} + .setErrorCallback { throwable -> result.error(errorGettingLibraryState, "error when getting the library state", throwable.toString()) } + } else { + spotifyRemoteAppNotSetError() + } + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/CapabilitiesChannel.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/CapabilitiesChannel.kt new file mode 100644 index 000000000..df15a164d --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/CapabilitiesChannel.kt @@ -0,0 +1,23 @@ +package de.minimalme.spotify_sdk.subscriptions + +import com.google.gson.Gson +import com.spotify.android.appremote.api.PlayerApi +import com.spotify.android.appremote.api.UserApi +import io.flutter.plugin.common.EventChannel + +class CapabilitiesChannel(private val userApi: UserApi) : EventChannel.StreamHandler { + + private val errorSubscribeCapabilities = "subscribeCapabilitiesError" + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + userApi.subscribeToCapabilities() + .setEventCallback { + capabilities -> events?.success(Gson().toJson(capabilities)) + } + .setErrorCallback { + throwable -> events?.error(errorSubscribeCapabilities, "error when subscribing to the users capabilities", throwable.toString()) + } + } + + override fun onCancel(arguments: Any?) { + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/ConnectionStatusChannel.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/ConnectionStatusChannel.kt new file mode 100644 index 000000000..4a24d46e2 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/ConnectionStatusChannel.kt @@ -0,0 +1,27 @@ +package de.minimalme.spotify_sdk.subscriptions + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import io.flutter.plugin.common.EventChannel +import kotlinx.event.SetEvent + +class ConnectionStatusChannel(private val connStatusStream: SetEvent) : EventChannel.StreamHandler { + + data class ConnectionEvent( + @SerializedName("connected") val connected: Boolean, + @SerializedName("message" )val message: String, + @SerializedName("errorCode") val errorCode: String?, + @SerializedName("errorDetails") val errorDetails: Any? + ) + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + connStatusStream += { event -> + // not error + events?.success(Gson().toJson(event)) + } + } + + override fun onCancel(arguments: Any?) { + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerContextChannel.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerContextChannel.kt new file mode 100644 index 000000000..cac23f48c --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerContextChannel.kt @@ -0,0 +1,22 @@ +package de.minimalme.spotify_sdk.subscriptions + +import com.google.gson.Gson +import com.spotify.android.appremote.api.PlayerApi +import io.flutter.plugin.common.EventChannel + +class PlayerContextChannel(private val playerApi: PlayerApi) : EventChannel.StreamHandler { + + private val errorSubscribePlayerContext = "subscribePlayerContextError" + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + playerApi.subscribeToPlayerContext() + .setEventCallback { + playerContext -> events?.success(Gson().toJson(playerContext)) + } + .setErrorCallback { + throwable -> events?.error(errorSubscribePlayerContext, "error when subscribing to the player context", throwable.toString()) + } + } + + override fun onCancel(arguments: Any?) { + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerStateChannel.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerStateChannel.kt new file mode 100644 index 000000000..1edb6823a --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerStateChannel.kt @@ -0,0 +1,22 @@ +package de.minimalme.spotify_sdk.subscriptions + +import com.google.gson.Gson +import com.spotify.android.appremote.api.PlayerApi +import io.flutter.plugin.common.EventChannel + +class PlayerStateChannel(private val playerApi: PlayerApi) : EventChannel.StreamHandler { + + private val errorSubscribePlayerState = "subscribePlayerStateError" + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + playerApi.subscribeToPlayerState() + .setEventCallback { + playerState -> events?.success(Gson().toJson(playerState)) + } + .setErrorCallback { + throwable -> events?.error(errorSubscribePlayerState, "error when subscribing to the player state", throwable.toString()) + } + } + + override fun onCancel(arguments: Any?) { + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/UserStatusChannel.kt b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/UserStatusChannel.kt new file mode 100644 index 000000000..a034b4982 --- /dev/null +++ b/local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/UserStatusChannel.kt @@ -0,0 +1,23 @@ +package de.minimalme.spotify_sdk.subscriptions + +import com.google.gson.Gson +import com.spotify.android.appremote.api.PlayerApi +import com.spotify.android.appremote.api.UserApi +import io.flutter.plugin.common.EventChannel + +class UserStatusChannel(private val userApi: UserApi) : EventChannel.StreamHandler { + + private val errorSubscribeUserStatus = "subscribeUserStatusError" + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + userApi.subscribeToUserStatus() + .setEventCallback { + userStatus -> events?.success(Gson().toJson(userStatus)) + } + .setErrorCallback { + throwable -> events?.error(errorSubscribeUserStatus, "error when subscribing to the users status", throwable.toString()) + } + } + + override fun onCancel(arguments: Any?) { + } +} \ No newline at end of file diff --git a/local_plugins/spotify_sdk/bin/android_cleanup.dart b/local_plugins/spotify_sdk/bin/android_cleanup.dart new file mode 100644 index 000000000..aca4d637d --- /dev/null +++ b/local_plugins/spotify_sdk/bin/android_cleanup.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'android_setup.dart'; + +/// Removes all changes made by the [android_setup] script. +Future main(List args) async { + logger.i('running android_cleanup script'); + + // remove the module directory if it exists + final moduleDir = Directory('android/$moduleName'); + if (moduleDir.existsSync()) { + await moduleDir.delete(recursive: true); + logger.t('deleted directory ${moduleDir.path}'); + } + + // remove the include statement from settings.gradle + final settingsFile = await File('android/settings.gradle').readAsString(); + const includeStatements = [ + "include ':$moduleName'", + 'include ":$moduleName"' + ]; + if (includeStatements.any((element) => settingsFile.contains(element))) { + final newSettingsFile = includeStatements.fold(settingsFile, + (previousValue, element) => previousValue.replaceAll(element, '')); + await File('android/settings.gradle').writeAsString(newSettingsFile); + logger.t('removed include statement from settings.gradle'); + } + + // remove the manifestPlaceholder from app/build.gradle + final appBuildFile = await File('android/app/build.gradle').readAsString(); + if (appBuildFile.contains('manifestPlaceholders')) { + final newAppBuildFile = appBuildFile + .split('\n') + .where((element) => !element.contains('manifestPlaceholders')) + .join('\n'); + await File('android/app/build.gradle').writeAsString(newAppBuildFile); + logger.t('removed manifestPlaceholders from app/build.gradle'); + } +} diff --git a/local_plugins/spotify_sdk/bin/android_module_creator.dart b/local_plugins/spotify_sdk/bin/android_module_creator.dart new file mode 100644 index 000000000..2074999ea --- /dev/null +++ b/local_plugins/spotify_sdk/bin/android_module_creator.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:logger/logger.dart'; + +import 'android_setup.dart'; + +/// Responsible for creating a new android module in the /android directory +/// of the flutter project. +class AndroidModuleCreator { + String moduleName; + String aarFileName; + + AndroidModuleCreator(this.moduleName, this.aarFileName); + + String _gradleFileContent(String aarFileName) => ''' +configurations.maybeCreate("default") +artifacts.add("default", file('$aarFileName')) +'''; + + String _readmeContent(String moduleName, String aarFileName) => ''' +# Spotify Android SDK for Flutter +This module (`:$moduleName`) was generated by running the `android_setup` script of +the [spotify_sdk](https://pub.dev/packages/spotify_sdk) plugin. + +If you want to update the `aar`-library, please run the `android_setup` script using +one of the following commands: +``` +dart run spotify_sdk:android_setup --cleanup +flutter pub run spotify_sdk:android_setup --cleanup +``` +'''; + + Future createModuleDirectory() async { + // create directory in /android + final aarDir = await Directory('android/$moduleName').create(); + logger.t('created new directory ${aarDir.path}'); + + // create build.gradle file + final gradleFile = await File('${aarDir.path}/build.gradle').create(); + logger.t('created new file ${gradleFile.path}'); + await gradleFile.writeAsString(_gradleFileContent(aarFileName)); + + _settingsGradle(logger); + + _appBuildGradle(logger); + + // create README.md file + final readmeFile = await File('${aarDir.path}/README.md').create(); + logger.t('created new file ${readmeFile.path}'); + await readmeFile.writeAsString(_readmeContent(moduleName, aarFileName)); + } + + void _settingsGradle(Logger logger) async { + // edit settings.gradle file + final settingsFile = await File('android/settings.gradle').readAsString(); + final includeStatement = "include ':$moduleName'"; + if (settingsFile.contains(includeStatement)) { + logger.t('settings.gradle already contains $includeStatement'); + } else { + final String newSettingsFile; + if (settingsFile.contains("include ':app'")) { + newSettingsFile = settingsFile.replaceFirst( + 'include \':app\'', 'include \':app\'\ninclude \':$moduleName\''); + } else { + newSettingsFile = settingsFile.replaceFirst( + 'include ":app"', 'include ":app"\ninclude ":$moduleName"'); + } + await File('android/settings.gradle').writeAsString(newSettingsFile); + logger.t('added "$includeStatement" to android/settings.gradle'); + } + } + + void _appBuildGradle(Logger logger) async { + // edit app/build.gradle file + final appBuildFile = await File('android/app/build.gradle').readAsString(); + if (!appBuildFile.contains('defaultConfig')) { + logger.e( + 'Error: The file "android/app/build.gradle" does not contain a defaultConfig block. ' + 'Your android project might be misconfigured.'); + return; + } + if (appBuildFile.contains('manifestPlaceholders')) { + logger.w( + 'android/app/build.gradle already contains manifestPlaceholders. ' + 'Make sure to update the redirect scheme and host name manually if necessary.'); + return; + } + + final String newAppBuildFile = appBuildFile.replaceFirst( + 'defaultConfig {', + 'defaultConfig {\n' + ' manifestPlaceholders = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth"]'); + await File('android/app/build.gradle').writeAsString(newAppBuildFile); + logger.t('added manifestPlaceholders to android/app/build.gradle'); + } +} diff --git a/local_plugins/spotify_sdk/bin/android_setup.dart b/local_plugins/spotify_sdk/bin/android_setup.dart new file mode 100644 index 000000000..0854d64b6 --- /dev/null +++ b/local_plugins/spotify_sdk/bin/android_setup.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:logger/logger.dart'; + +import 'android_module_creator.dart'; +import 'github_api.dart'; +import 'precondition_checker.dart'; + +import 'android_cleanup.dart' as cleanup; + +const String scriptName = 'android_setup'; +const String moduleName = 'spotify-app-remote'; + +final logger = Logger(filter: ScriptLogFilter(), printer: SimplePrinter()); +Level loglevel = Level.info; + +Future main(List args) async { + if (args.contains('--help')) { + logger.i(''' + Usage: $scriptName [options] + Options: + --help: show this help message + --verbose: show all logs + --cleanup: runs the cleanup script before executing the setup script to remove all previously created changes + --sdk-version: the version of the Spotify Android SDK, default is the latest release on GitHub (eg. --sdk-version=0.8.0) + '''); + return; + } + + if (args.contains('--verbose')) { + loglevel = Level.trace; + logger.t('verbose logging enabled'); + } + + if (args.contains('--cleanup')) { + logger.t('running cleanup script'); + await cleanup.main(args); + } + + if (!PreconditionChecker.setupConditionsMet()) { + logger.e('$scriptName can not be executed, ' + 'please make sure to meet all requirements and try again.'); + } else { + logger.i('running $scriptName script'); + String? sdkVersion = args.cast().firstWhere( + (element) => element?.startsWith('--sdk-version=') ?? false, + orElse: () => null); + _runSetup(sdkVersion: sdkVersion); + } +} + +/// Runs the setup process. +void _runSetup({String? sdkVersion}) async { + Uri url; + String name; + try { + if (sdkVersion == null) { + (name, url) = + await GitHubApi.fetchLatestAppRemoteReleaseAssetDownloadUrl(); + } else { + (name, url) = + await GitHubApi.fetchVersionedAppRemoteReleaseAssetDownloadUrl( + sdkVersion); + } + } catch (e) { + logger.e('Failed to fetch the Spotify Android SDK asset, terminating.'); + return; + } + + // create the new module directory + final destination = File('android/$moduleName/$name') + ..createSync(recursive: true); + logger.t('created new file ${destination.path}'); + + // download the aar file to the new destination module + final client = HttpClient(); + final request = await client.getUrl(url); + final response = await request.close(); + await response.pipe(destination.openWrite()); + client.close(); + logger.t('downloaded $name to ${destination.path}'); + + // create the new module + await AndroidModuleCreator(moduleName, name).createModuleDirectory(); +} + +/// Log filter to show all logs when running the script in release mode. +class ScriptLogFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) => event.level.index >= loglevel.index; +} diff --git a/local_plugins/spotify_sdk/bin/github_api.dart b/local_plugins/spotify_sdk/bin/github_api.dart new file mode 100644 index 000000000..0b8606152 --- /dev/null +++ b/local_plugins/spotify_sdk/bin/github_api.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'android_setup.dart'; + +/// [fetchLatestAppRemoteReleaseAssetDownloadUrl] fetches the latest release of the +/// Spotify Android SDK from GitHub API and returns the name and download url of +/// the spotify-app-remote-release-*.aar asset. +/// Throws an exception if the request fails. +class GitHubApi { + static const String apiUrl = 'https://api.github.com/repos'; + static const String spotifyAndroidSdkRepo = '/spotify/android-sdk'; + static const String latestRelease = '/releases/latest'; + static const String allReleases = '/releases'; + + static Future<(String, Uri)> + fetchLatestAppRemoteReleaseAssetDownloadUrl() async { + // fetch the github api to get the latest release + Uri uri = Uri.parse(apiUrl + spotifyAndroidSdkRepo + latestRelease); + try { + final response = await http.get(uri); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return _findAsset(data); + } else { + logger.e('Failed to fetch data from the API.'); + } + } catch (e) { + logger.e('An error occurred: $e'); + } + throw Exception(); + } + + static Future<(String, Uri)> fetchVersionedAppRemoteReleaseAssetDownloadUrl( + String sdkVersion) async { + if (sdkVersion.startsWith('--sdk-version=')) { + sdkVersion = sdkVersion.substring(14); + } + if (sdkVersion.startsWith('v')) { + sdkVersion = sdkVersion.substring(1); + } + + // fetch the github api to get all releases + Uri uri = Uri.parse(apiUrl + spotifyAndroidSdkRepo + allReleases); + try { + final response = await http.get(uri); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as List; + + logger.t( + 'Found ${data.length} releases of the Spotify Android SDK on GitHub.'); + final releaseData = data.firstWhere( + (element) { + final tagName = element['tag_name'] as String; + return tagName.contains('v$sdkVersion'); + }, + orElse: () { + logger.e('Failed to find a release with version "$sdkVersion".'); + throw Exception(); + }, + ); + + return _findAsset(releaseData); + } + } catch (e) { + logger.e('An error occurred: $e'); + } + throw Exception(); + } + + static (String, Uri) _findAsset(Map data) { + final assets = data['assets'] as List; + + final assetMap = {}; + for (var asset in assets) { + final id = asset['id'] as int; + final name = asset['name'] as String; + final url = asset['browser_download_url'] as String; + assetMap[id] = (name, url); + } + + // find the spotify-app-remote-release-*.aar asset + final assetId = assetMap.keys.firstWhere( + (id) => + assetMap[id]?.$1.startsWith('spotify-app-remote-release-') ?? false, + orElse: () => -1, + ); + + if (assetId == -1) { + logger.e('Failed to find the Spotify Android SDK asset.'); + throw Exception(); + } + + // return the download url of the spotify-app-remote-release-*.aar asset + logger.i('Found the Spotify Android SDK asset: ' + '${assetMap[assetId]!.$1}'); + return (assetMap[assetId]!.$1, Uri.parse(assetMap[assetId]!.$2)); + } +} diff --git a/local_plugins/spotify_sdk/bin/precondition_checker.dart b/local_plugins/spotify_sdk/bin/precondition_checker.dart new file mode 100644 index 000000000..dbe49a412 --- /dev/null +++ b/local_plugins/spotify_sdk/bin/precondition_checker.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'android_setup.dart'; + +/// Checks if all preconditions are met to execute the script. +/// Returns true if all preconditions are met, false otherwise. +class PreconditionChecker { + static bool setupConditionsMet() { + // check if the script is executed on a supported platform + if (!Platform.isMacOS && !Platform.isLinux) { + logger.w( + 'Warning: This script has not been tested on your platform (${Platform.operatingSystem}).'); + } + + // check if flutter is installed + if (!(Platform.environment['PATH']?.contains("flutter") ?? false) && + Platform.environment['FLUTTER_ROOT'] == null) { + logger.e('Error: Flutter is not installed or not in your PATH.'); + return false; + } + + // check if the script is executed from inside a flutter project + if (!File('pubspec.yaml').existsSync()) { + logger.e( + 'Error: The script must be executed from inside a flutter project.'); + return false; + } + + // check if the necessary android files exist + if (!File('android/app/build.gradle').existsSync()) { + logger.e('Error: The file "android/app/build.gradle" does not exist.'); + return false; + } + + // check if the setup may have already been executed and recommend to run the cleanup script + bool prevRun = Directory('android/$moduleName').existsSync() || + File('android/$moduleName/build.gradle').existsSync(); + if (!prevRun && File('android/settings.gradle').existsSync()) { + final settingsFile = File('android/settings.gradle').readAsStringSync(); + prevRun |= settingsFile.contains("include ':$moduleName'"); + prevRun |= settingsFile.contains('include ":$moduleName"'); + } + + if (prevRun) { + logger.w('Warning: The setup may have already been executed. ' + 'Please run dart run spotify_sdk:android_setup --cleanup before running this script again.'); + return false; + } + + return true; + } +} diff --git a/local_plugins/spotify_sdk/ios/.gitignore b/local_plugins/spotify_sdk/ios/.gitignore new file mode 100644 index 000000000..aa479fd3c --- /dev/null +++ b/local_plugins/spotify_sdk/ios/.gitignore @@ -0,0 +1,37 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/local_plugins/spotify_sdk/ios/Assets/.gitkeep b/local_plugins/spotify_sdk/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/spotify_sdk/ios/Classes/ConnectionStatusHandler.swift b/local_plugins/spotify_sdk/ios/Classes/ConnectionStatusHandler.swift new file mode 100644 index 000000000..ce2ea0493 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/ConnectionStatusHandler.swift @@ -0,0 +1,45 @@ +import SpotifyiOS + +class ConnectionStatusHandler: StatusHandler, SPTAppRemoteDelegate { + + var tokenResult: FlutterResult? + var connectionResult: FlutterResult? + + func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) { + connectionResult?(true) + tokenResult?(appRemote.connectionParameters.accessToken) + eventSink?("{\"connected\": true}") + + connectionResult = nil + tokenResult = nil + } + + func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: Error?) { + defer { + connectionResult = nil + tokenResult = nil + } + + if error != nil { + // report spotify remote error to plugin + eventSink?("{\"connected\": false, \"errorCode\": \"\(error!._code)\", \"errorDetails\": \"\(error!.localizedDescription)\"}") + connectionResult?(FlutterError(code: String(error!._code), message: error!.localizedDescription, details: nil)) + tokenResult?(FlutterError(code: String(error!._code), message: error!.localizedDescription, details: nil)) + } else { + // report disconnection to plugin + eventSink?("{\"connected\": false}") + connectionResult?(FlutterError(code: "errorConnection", message: "Failed Connection Attempt", details: nil)) + tokenResult?(FlutterError(code: "errorConnection", message: "Failed Connection Attempt", details: nil)) + } + } + + func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: Error?) { + if error != nil { + // report spotify remote error to plugin + eventSink?("{\"connected\": false, \"errorCode\": \"\(error!._code)\", \"errorDetails\": \"\(error!.localizedDescription)\"}") + } else { + // report disconnection to plugin + eventSink?("{\"connected\": false}") + } + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/Extension/Dictionary.swift b/local_plugins/spotify_sdk/ios/Classes/Extension/Dictionary.swift new file mode 100644 index 000000000..831068ed8 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/Extension/Dictionary.swift @@ -0,0 +1,13 @@ +import Foundation + +extension Dictionary { + var json: String { + let invalidJson = "Invalid JSON" + do { + let jsonData = try JSONSerialization.data(withJSONObject: self, options: .prettyPrinted) + return String(bytes: jsonData, encoding: String.Encoding.utf8) ?? invalidJson + } catch { + return invalidJson + } + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/PlayerDelegate.swift b/local_plugins/spotify_sdk/ios/Classes/PlayerDelegate.swift new file mode 100644 index 000000000..f99391052 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/PlayerDelegate.swift @@ -0,0 +1,14 @@ +import SpotifyiOS +import Flutter + +class PlayerDelegate: NSObject, SPTAppRemotePlayerStateDelegate { + + var playerStateSink: FlutterEventSink? + var playerContextSink: FlutterEventSink? + + func playerStateDidChange(_ playerState: SPTAppRemotePlayerState) { + print("playerStateDidChange") + playerStateSink?(State.playerStateDictionary(playerState).json) + playerContextSink?(State.playerContextDictionary(playerState).json) + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerContextHandler.swift b/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerContextHandler.swift new file mode 100644 index 000000000..32b333f13 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerContextHandler.swift @@ -0,0 +1,18 @@ +import SpotifyiOS + +class PlayerContextHandler: StatusHandler { + private let appRemote: SPTAppRemote + private let playerDelegate: PlayerDelegate + + init (appRemote: SPTAppRemote, playerDelegate: PlayerDelegate) { + self.appRemote = appRemote + self.playerDelegate = playerDelegate + super.init() + } + + override func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + _ = super.onListen(withArguments: arguments, eventSink: events) + playerDelegate.playerContextSink = events + return nil + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerState.swift b/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerState.swift new file mode 100644 index 000000000..067d99b84 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerState.swift @@ -0,0 +1,85 @@ +import SpotifyiOS + +struct State { + static func playerStateDictionary(_ playerState: SPTAppRemotePlayerState) -> [String : Any] { + let artist = [ + "name" : playerState.track.artist.name, + "uri" : playerState.track.artist.uri + ] + + let album = [ + "name" : playerState.track.album.name, + "uri" : playerState.track.album.uri + ] + + let imageIdentifier = [ + "raw" : playerState.track.imageIdentifier + + ] + + let track: [String : Any] = [ + "album" : album, + "artist" : artist, + "artists" : [artist], + "duration_ms" : playerState.track.duration, + "image_id" : imageIdentifier, + "is_episode" : playerState.track.isEpisode, + "is_podcast" : playerState.track.isPodcast, + "name" : playerState.track.name, + "uri" : playerState.track.uri + ] + + let playbackOptions: [String : Any] = [ + "shuffle" : playerState.playbackOptions.isShuffling, + "repeat" : playerState.playbackOptions.repeatMode.rawValue + ] + + let playbackRestrictions = [ + "can_skip_next" : playerState.playbackRestrictions.canSkipNext, + "can_skip_prev" : playerState.playbackRestrictions.canSkipPrevious, + "can_repeat_track" : playerState.playbackRestrictions.canRepeatTrack, + "can_repeat_context" : playerState.playbackRestrictions.canRepeatContext, + "can_toggle_shuffle" : playerState.playbackRestrictions.canToggleShuffle, + "can_seek" : playerState.playbackRestrictions.canSeek + ] + + return [ + "track" : track, + "is_paused" : playerState.isPaused, + "playback_speed" : playerState.playbackSpeed, + "playback_position" : playerState.playbackPosition, + "playback_options" : playbackOptions, + "playback_restrictions" : playbackRestrictions + ] + } + + static func playerContextDictionary(_ playerState: SPTAppRemotePlayerState) -> [String : Any] { + return [ + "title" : playerState.contextTitle, + "subtitle" : playerState.contextTitle, + "type" : playerState.contextURI.absoluteString.components(separatedBy: ":")[1], + "uri" : playerState.contextURI.absoluteString + ] + } + + static func crossfadeStateDictionary(_ crossfadeState: SPTAppRemoteCrossfadeState) -> [String : Any] { + return [ + "duration": crossfadeState.duration, + "isEnabled": crossfadeState.isEnabled, + ] + } + + static func userCapabilitiesDictionary(_ userCapabilities: SPTAppRemoteUserCapabilities) -> [String : Any] { + return [ + "can_play_on_demand": userCapabilities.canPlayOnDemand + ] + } + + static func libraryStateDictionary(_ libraryState: SPTAppRemoteLibraryState) -> [String : Any] { + return [ + "uri": libraryState.uri, + "saved": libraryState.isAdded, + "can_save": libraryState.canAdd, + ] + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerStateHandler.swift b/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerStateHandler.swift new file mode 100644 index 000000000..fd5f7e814 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerStateHandler.swift @@ -0,0 +1,22 @@ +import SpotifyiOS + +class PlayerStateHandler: StatusHandler { + private let appRemote: SPTAppRemote + private let playerDelegate: PlayerDelegate + + init (appRemote: SPTAppRemote, playerDelegate: PlayerDelegate) { + self.appRemote = appRemote + self.playerDelegate = playerDelegate + super.init() + } + + override func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + _ = super.onListen(withArguments: arguments, eventSink: events) + playerDelegate.playerStateSink = events + appRemote.playerAPI?.delegate = playerDelegate + appRemote.playerAPI?.subscribe { (_, error) -> Void in + guard error == nil else { return } + } + return nil + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/SpotifyError.swift b/local_plugins/spotify_sdk/ios/Classes/SpotifyError.swift new file mode 100644 index 000000000..062bedfa2 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/SpotifyError.swift @@ -0,0 +1,4 @@ +enum SpotifyError: Error { + case spotifyNotInstalledError + case redirectURLInvalid +} diff --git a/local_plugins/spotify_sdk/ios/Classes/SpotifySdkConstants.swift b/local_plugins/spotify_sdk/ios/Classes/SpotifySdkConstants.swift new file mode 100644 index 000000000..ec5550cd5 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/SpotifySdkConstants.swift @@ -0,0 +1,46 @@ +import Foundation + +public class SpotifySdkConstants +{ + //connecting + public static let methodConnectToSpotify = "connectToSpotify" + public static let methodGetAccessToken = "getAccessToken" + public static let methodDisconnectFromSpotify = "disconnectFromSpotify" + + //player api + public static let methodQueueTrack = "queueTrack" + public static let methodPlay = "play" + public static let methodPause = "pause" + public static let methodResume = "resume" + public static let methodSkipNext = "skipNext" + public static let methodSkipPrevious = "skipPrevious" + public static let methodSkipToIndex = "skipToIndex" + public static let methodSeekTo = "seekTo" + public static let methodGetPlayerState = "getPlayerState" + public static let methodGetCrossfadeState = "getCrossfadeState" + public static let methodSetShuffle = "setShuffle" + public static let methodSetRepeatMode = "setRepeatMode" + + //user api + public static let methodAddToLibrary = "addToLibrary" + public static let methodRemoveFromLibrary = "removeFromLibrary" + public static let methodGetCapabilities = "getCapabilities" + + //images api + public static let methodGetImage = "getImage" + + public static let paramClientId = "clientId" + public static let paramRedirectUrl = "redirectUrl" + public static let paramSpotifyUri = "spotifyUri" + public static let paramAsRadio = "asRadio" + public static let paramImageUri = "imageUri" + public static let paramImageDimension = "imageDimension" + public static let paramPositionedMilliseconds = "positionedMilliseconds" + public static let paramRelativeMilliseconds = "relativeMilliseconds" + public static let paramAccessToken = "accessToken" + public static let paramShuffle = "shuffle" + public static let paramRepeatMode = "repeatMode" + public static let paramTrackIndex = "trackIndex" + public static let scope = "scope" + public static let getLibraryState = "getLibraryState" +} diff --git a/local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.h b/local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.h new file mode 100644 index 000000000..b6a0c0a91 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface SpotifySdkPlugin : NSObject +@end diff --git a/local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.m b/local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.m new file mode 100644 index 000000000..427325650 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.m @@ -0,0 +1,18 @@ +#import "SpotifySdkPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "spotify_sdk-Swift.h" +#endif + +#import + +@implementation SpotifySdkPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftSpotifySdkPlugin registerWithRegistrar:registrar]; + +} +@end diff --git a/local_plugins/spotify_sdk/ios/Classes/StatusHandler.swift b/local_plugins/spotify_sdk/ios/Classes/StatusHandler.swift new file mode 100644 index 000000000..62ffaf840 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/StatusHandler.swift @@ -0,0 +1,17 @@ +import Flutter +import UIKit +import SpotifyiOS + +class StatusHandler: NSObject, FlutterStreamHandler { + var eventSink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } +} diff --git a/local_plugins/spotify_sdk/ios/Classes/SwiftSpotifySdkPlugin.swift b/local_plugins/spotify_sdk/ios/Classes/SwiftSpotifySdkPlugin.swift new file mode 100644 index 000000000..45e136b91 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/Classes/SwiftSpotifySdkPlugin.swift @@ -0,0 +1,416 @@ +import Flutter +import SpotifyiOS + +public class SwiftSpotifySdkPlugin: NSObject, FlutterPlugin { + private static var instance = SwiftSpotifySdkPlugin() + private var appRemote: SPTAppRemote? + private var connectionStatusHandler: ConnectionStatusHandler? + private var playerStateHandler: PlayerStateHandler? + private var playerContextHandler: PlayerContextHandler? + private static var playerStateChannel: FlutterEventChannel? + private static var playerContextChannel: FlutterEventChannel? + + public static func register(with registrar: FlutterPluginRegistrar) { + guard playerStateChannel == nil else { + // Avoid multiple plugin registations + return + } + let spotifySDKChannel = FlutterMethodChannel(name: "spotify_sdk", binaryMessenger: registrar.messenger()) + let connectionStatusChannel = FlutterEventChannel(name: "connection_status_subscription", binaryMessenger: registrar.messenger()) + playerStateChannel = FlutterEventChannel(name: "player_state_subscription", binaryMessenger: registrar.messenger()) + playerContextChannel = FlutterEventChannel(name: "player_context_subscription", binaryMessenger: registrar.messenger()) + registrar.addApplicationDelegate(instance) + registrar.addMethodCallDelegate(instance, channel: spotifySDKChannel) + instance.connectionStatusHandler = ConnectionStatusHandler() + connectionStatusChannel.setStreamHandler(instance.connectionStatusHandler) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + var defaultPlayAPICallback: SPTAppRemoteCallback { + get { + return {_, error in + if let error = error { + result(FlutterError(code: "PlayerAPI Error", message: error.localizedDescription, details: nil)) + } else { + result(true) + } + } + } + } + + switch call.method { + case SpotifySdkConstants.methodConnectToSpotify: + guard let swiftArguments = call.arguments as? [String:Any], + let clientID = swiftArguments[SpotifySdkConstants.paramClientId] as? String, + !clientID.isEmpty else { + result(FlutterError(code: "Argument Error", message: "Client ID is not set", details: nil)) + return + } + + guard let url = swiftArguments[SpotifySdkConstants.paramRedirectUrl] as? String, + !url.isEmpty else { + result(FlutterError(code: "Argument Error", message: "Redirect URL is not set", details: nil)) + return + } + + connectionStatusHandler?.connectionResult = result + + + let accessToken: String? = swiftArguments[SpotifySdkConstants.paramAccessToken] as? String + let spotifyUri: String = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String ?? "" + + do { + try connectToSpotify(clientId: clientID, redirectURL: url, accessToken: accessToken, spotifyUri: spotifyUri, asRadio: swiftArguments[SpotifySdkConstants.paramAsRadio] as? Bool, additionalScopes: swiftArguments[SpotifySdkConstants.scope] as? String) + } + catch SpotifyError.redirectURLInvalid { + result(FlutterError(code: "errorConnecting", message: "Redirect URL is not set or has invalid format", details: nil)) + } + catch { + result(FlutterError(code: "CouldNotFindSpotifyApp", message: "The Spotify app is not installed on the device", details: nil)) + return + } + + case SpotifySdkConstants.methodGetAccessToken: + guard let swiftArguments = call.arguments as? [String:Any], + let clientID = swiftArguments[SpotifySdkConstants.paramClientId] as? String, + let url = swiftArguments[SpotifySdkConstants.paramRedirectUrl] as? String else { + result(FlutterError(code: "Arguments Error", message: "One or more arguments are missing", details: nil)) + return + } + connectionStatusHandler?.tokenResult = result + let spotifyUri: String = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String ?? "" + + do { + try connectToSpotify(clientId: clientID, redirectURL: url, spotifyUri: spotifyUri, asRadio: swiftArguments[SpotifySdkConstants.paramAsRadio] as? Bool, additionalScopes: swiftArguments[SpotifySdkConstants.scope] as? String) + } + catch SpotifyError.redirectURLInvalid { + result(FlutterError(code: "errorConnecting", message: "Redirect URL is not set or has invalid format", details: nil)) + } + catch { + result(FlutterError(code: "CouldNotFindSpotifyApp", message: "The Spotify app is not installed on the device", details: nil)) + return + } + case SpotifySdkConstants.methodGetImage: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let paramImageUri = swiftArguments[SpotifySdkConstants.paramImageUri] as? String, + let paramImageDimension = swiftArguments[SpotifySdkConstants.paramImageDimension] as? Int else { + result(FlutterError(code: "Arguments Error", message: "One or more arguments are missing", details: nil)) + return + } + + class ImageObject: NSObject, SPTAppRemoteImageRepresentable { + var imageIdentifier: String = "" + } + + let imageObject = ImageObject() + imageObject.imageIdentifier = paramImageUri + appRemote.imageAPI?.fetchImage(forItem: imageObject, with: CGSize(width: paramImageDimension, height: paramImageDimension), callback: { (image, error) in + guard error == nil else { + result(FlutterError(code: "ImageAPI Error", message: error?.localizedDescription, details: nil)) + return + } + guard let imageData = (image as? UIImage)?.pngData() else { + result(FlutterError(code: "ImageAPI Error", message: "Image is empty", details: nil)) + return + } + result(imageData) + }) + case SpotifySdkConstants.methodGetPlayerState: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + + appRemote.playerAPI?.getPlayerState({ (playerState, error) in + guard error == nil else { + result(FlutterError(code: "PlayerAPI Error", message: error?.localizedDescription, details: nil)) + return + } + guard let playerState = playerState as? SPTAppRemotePlayerState else { + result(FlutterError(code: "PlayerAPI Error", message: "PlayerState is empty", details: nil)) + return + } + result(State.playerStateDictionary(playerState).json) + }) + case SpotifySdkConstants.methodDisconnectFromSpotify: + appRemote?.disconnect() +// appRemote?.connectionParameters.accessToken = nil + result(true) + case SpotifySdkConstants.methodPlay: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let uri = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + let asRadio: Bool = (swiftArguments[SpotifySdkConstants.paramAsRadio] as? Bool) ?? false + appRemote.playerAPI?.play(uri, asRadio: asRadio, callback: defaultPlayAPICallback) + case SpotifySdkConstants.methodPause: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + appRemote.playerAPI?.pause(defaultPlayAPICallback) + case SpotifySdkConstants.methodResume: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + appRemote.playerAPI?.resume(defaultPlayAPICallback) + case SpotifySdkConstants.methodSkipNext: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + appRemote.playerAPI?.skip(toNext: defaultPlayAPICallback) + case SpotifySdkConstants.methodSkipPrevious: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + appRemote.playerAPI?.skip(toPrevious: { (spotifyResult, error) in + if let error = error { + result(FlutterError(code: "PlayerAPI Error", message: error.localizedDescription, details: nil)) + return + } + result(true) + }) + case SpotifySdkConstants.methodSkipToIndex: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let uri = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + let index = (swiftArguments[SpotifySdkConstants.paramTrackIndex] as? Int) ?? 0 + + appRemote.contentAPI?.fetchContentItem(forURI: uri, callback: { (contentItemResult, error) in + guard error == nil else { + result(FlutterError(code: "PlayerAPI Error", message: error?.localizedDescription, details: nil)) + return + } + guard let contentItem = contentItemResult as? SPTAppRemoteContentItem else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + appRemote.playerAPI?.play(contentItem, skipToTrackIndex: index, callback: defaultPlayAPICallback) + }) + + case SpotifySdkConstants.methodAddToLibrary: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let uri = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + appRemote.userAPI?.addItemToLibrary(withURI: uri, callback: defaultPlayAPICallback) + case SpotifySdkConstants.methodRemoveFromLibrary: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let uri = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + appRemote.userAPI?.removeItemFromLibrary(withURI: uri, callback: defaultPlayAPICallback) + case SpotifySdkConstants.methodGetCapabilities: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + appRemote.userAPI?.fetchCapabilities(callback: { (capabilitiesResult, error) in + guard error == nil else { + result(FlutterError(code: "getCapabilitiesError", message: error?.localizedDescription, details: nil)) + return + } + guard let userCapabilities = capabilitiesResult as? SPTAppRemoteUserCapabilities else { + result(FlutterError(code: "getCapabilitiesError", message: error?.localizedDescription, details: nil)) + return + } + + result(State.userCapabilitiesDictionary(userCapabilities).json) + }) + case SpotifySdkConstants.methodQueueTrack: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let uri = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + appRemote.playerAPI?.enqueueTrackUri(uri, callback: defaultPlayAPICallback) + case SpotifySdkConstants.methodSeekTo: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let position = swiftArguments[SpotifySdkConstants.paramPositionedMilliseconds] as? Int else { + result(FlutterError(code: "Position error", message: "No position was specified", details: nil)) + return + } + appRemote.playerAPI?.seek(toPosition: position, callback: defaultPlayAPICallback) + case SpotifySdkConstants.methodGetCrossfadeState: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + appRemote.playerAPI?.getCrossfadeState({ (crossfadeState, error) in + guard error == nil else { + result(FlutterError(code: "PlayerAPI Error", message: error?.localizedDescription, details: nil)) + return + } + guard let crossfadeState = crossfadeState as? SPTAppRemoteCrossfadeState else { + result(FlutterError(code: "PlayerAPI Error", message: "PlayerState is empty", details: nil)) + return + } + result(State.crossfadeStateDictionary(crossfadeState).json) + }) + case SpotifySdkConstants.methodSetShuffle: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let shuffle = swiftArguments[SpotifySdkConstants.paramShuffle] as? Bool else { + result(FlutterError(code: "Shuffle mode error", message: "No ShuffleMode was specified", details: nil)) + return + } + appRemote.playerAPI?.setShuffle(shuffle, callback: defaultPlayAPICallback) + case SpotifySdkConstants.methodSetRepeatMode: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let repeatModeIndex = swiftArguments[SpotifySdkConstants.paramRepeatMode] as? UInt, + let repeatMode = SPTAppRemotePlaybackOptionsRepeatMode(rawValue: repeatModeIndex) else { + result(FlutterError(code: "Repeat mode error", message: "No RepeatMode was specified", details: nil)) + return + } + appRemote.playerAPI?.setRepeatMode(repeatMode, callback: defaultPlayAPICallback) + case SpotifySdkConstants.getLibraryState: + guard let appRemote = appRemote else { + result(FlutterError(code: "Connection Error", message: "AppRemote is null", details: nil)) + return + } + guard let swiftArguments = call.arguments as? [String:Any], + let uri = swiftArguments[SpotifySdkConstants.paramSpotifyUri] as? String else { + result(FlutterError(code: "URI Error", message: "No URI was specified", details: nil)) + return + } + appRemote.userAPI?.fetchLibraryState(forURI: uri, callback: {libraryStateResult, error in + guard error == nil else { + result(FlutterError(code: "fetchLibraryStateError", message: error?.localizedDescription, details: nil)) + return + } + guard let libraryState = libraryStateResult as? SPTAppRemoteLibraryState else { + result(FlutterError(code: "fetchLibraryStateError", message: error?.localizedDescription, details: nil)) + return + } + + result(State.libraryStateDictionary(libraryState).json) + }) + default: + result(FlutterMethodNotImplemented) + } + } + + private func connectToSpotify(clientId: String, redirectURL: String, accessToken: String? = nil, spotifyUri: String = "", asRadio: Bool?, additionalScopes: String? = nil) throws { + func configureAppRemote(clientID: String, redirectURL: String, accessToken: String? = nil) throws { + guard let redirectURL = URL(string: redirectURL) else { + throw SpotifyError.redirectURLInvalid + } + let configuration = SPTConfiguration(clientID: clientID, redirectURL: redirectURL) + let appRemote = SPTAppRemote(configuration: configuration, logLevel: .none) + appRemote.delegate = connectionStatusHandler + let playerDelegate = PlayerDelegate() + playerStateHandler = PlayerStateHandler(appRemote: appRemote, playerDelegate: playerDelegate) + SwiftSpotifySdkPlugin.playerStateChannel?.setStreamHandler(playerStateHandler) + + playerContextHandler = PlayerContextHandler(appRemote: appRemote, playerDelegate: playerDelegate) + SwiftSpotifySdkPlugin.playerContextChannel?.setStreamHandler(playerContextHandler) + + appRemote.connectionParameters.accessToken = accessToken + self.appRemote = appRemote + } + + try configureAppRemote(clientID: clientId, redirectURL: redirectURL, accessToken: accessToken) + + var scopes: [String]? + if let additionalScopes = additionalScopes { + scopes = additionalScopes.components(separatedBy: ",") + } + + if accessToken != nil { + appRemote?.connect() + } else { + // Note: A blank string will play the user's last song or pick a random one. + self.appRemote?.authorizeAndPlayURI(spotifyUri, asRadio: asRadio ?? false, additionalScopes: scopes) { success in + if (!success) { + self.connectionStatusHandler?.connectionResult?(FlutterError(code: "spotifyNotInstalled", message: "Spotify app is not installed", details: nil)) + } + } + } + } +} + +extension SwiftSpotifySdkPlugin { + public func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + setAccessTokenFromURL(url: url) + return true + } + + public func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]) -> Void) -> Bool { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL + else { + connectionStatusHandler?.connectionResult?(FlutterError(code: "errorConnecting", message: "client id or redirectUrl is invalid", details: nil)) + connectionStatusHandler?.tokenResult?(FlutterError(code: "errorConnecting", message: "client id or redirectUrl is invalid", details: nil)) + connectionStatusHandler?.connectionResult = nil + connectionStatusHandler?.tokenResult = nil + return false + } + + setAccessTokenFromURL(url: url) + return false + } + + private func setAccessTokenFromURL(url: URL) { + guard let appRemote = appRemote else { + connectionStatusHandler?.connectionResult?(FlutterError(code: "errorConnection", message: "AppRemote is null", details: nil)) + connectionStatusHandler?.tokenResult?(FlutterError(code: "errorConnection", message: "AppRemote is null", details: nil)) + connectionStatusHandler?.connectionResult = nil + connectionStatusHandler?.tokenResult = nil + return + } + + guard let token = appRemote.authorizationParameters(from: url)?[SPTAppRemoteAccessTokenKey] else { + connectionStatusHandler?.connectionResult?(FlutterError(code: "authenticationTokenError", message: appRemote.authorizationParameters(from: url)?[SPTAppRemoteErrorDescriptionKey], details: nil)) + connectionStatusHandler?.tokenResult?(FlutterError(code: "authenticationTokenError", message: appRemote.authorizationParameters(from: url)?[SPTAppRemoteErrorDescriptionKey], details: nil)) + connectionStatusHandler?.connectionResult = nil + connectionStatusHandler?.tokenResult = nil + return + } + + appRemote.connectionParameters.accessToken = token + appRemote.connect() + } +} diff --git a/local_plugins/spotify_sdk/ios/prepare-iOS-SDK.sh b/local_plugins/spotify_sdk/ios/prepare-iOS-SDK.sh new file mode 100755 index 000000000..7f66afd4f --- /dev/null +++ b/local_plugins/spotify_sdk/ios/prepare-iOS-SDK.sh @@ -0,0 +1,10 @@ +#!/bin/sh +REPO_NAME="ios-sdk" +FRAMEWORK_NAME="SpotifyiOS.xcframework" + +#!/bin/sh +rm -fR ${REPO_NAME} +mkdir ${REPO_NAME} +git clone https://github.com/spotify/${REPO_NAME} +git -C ${REPO_NAME} checkout tags/v3.0.0 +find ./${REPO_NAME} -mindepth 1 -maxdepth 1 -not -name ${FRAMEWORK_NAME} -exec rm -rf '{}' \; # Keep on only the xcframework folder diff --git a/local_plugins/spotify_sdk/ios/spotify_sdk.podspec b/local_plugins/spotify_sdk/ios/spotify_sdk.podspec new file mode 100644 index 000000000..7be779eb9 --- /dev/null +++ b/local_plugins/spotify_sdk/ios/spotify_sdk.podspec @@ -0,0 +1,26 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint spotify_sdk.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'spotify_sdk' + s.version = '0.0.1' + s.summary = 'Unofficial Spotify Flutter SDK.' + s.description = <<-DESC +Unofficial Spotify Flutter SDK. + DESC + s.homepage = 'https://github.com/brim-borium/spotify_sdk' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'fdimanidis@gmail.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + s.ios.deployment_target = '12.0' + # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.swift_version = '5.0' + s.prepare_command = './prepare-iOS-SDK.sh' + s.vendored_frameworks = 'ios-sdk/SpotifyiOS.xcframework' + s.preserve_paths = 'ios-sdk/SpotifyiOS.xcframework' + end diff --git a/local_plugins/spotify_sdk/lib/enums/image_dimension_enum.dart b/local_plugins/spotify_sdk/lib/enums/image_dimension_enum.dart new file mode 100644 index 000000000..1e4105405 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/enums/image_dimension_enum.dart @@ -0,0 +1,17 @@ +/// Holds the values from the spotify api for supported Image dimensions +enum ImageDimension { + /// large image + large, + + ///medium image + medium, + + /// small image + small, + + ///xsmall image + xSmall, + + /// thumbnail image + thumbnail +} diff --git a/local_plugins/spotify_sdk/lib/enums/podcast_playback_speed.dart b/local_plugins/spotify_sdk/lib/enums/podcast_playback_speed.dart new file mode 100644 index 000000000..01f27bc11 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/enums/podcast_playback_speed.dart @@ -0,0 +1,23 @@ +/// Holds the values from the spotify api for supported Podcast playback speeds +enum PodcastPlaybackSpeed { + /// 0.5 x playback speed + playbackSpeed_50, + + /// 0.8 x playback speed + playbackSpeed_80, + + /// 1 x playback speed + playbackSpeed_100, + + /// 1.2 x playback speed + playbackSpeed_120, + + /// 1.5 x playback speed + playbackSpeed_150, + + /// 2 x playback speed + playbackSpeed_200, + + /// 3 x playback speed + playbackSpeed_300, +} diff --git a/local_plugins/spotify_sdk/lib/enums/repeat_mode_enum.dart b/local_plugins/spotify_sdk/lib/enums/repeat_mode_enum.dart new file mode 100644 index 000000000..94cd0437f --- /dev/null +++ b/local_plugins/spotify_sdk/lib/enums/repeat_mode_enum.dart @@ -0,0 +1,11 @@ +/// Holds the values from the spotify api for RepeatModes +enum RepeatMode { + /// repeat is off + off, + + /// repeats the current track + track, + + /// repeats the current context + context, +} diff --git a/local_plugins/spotify_sdk/lib/extensions/image_dimension_extension.dart b/local_plugins/spotify_sdk/lib/extensions/image_dimension_extension.dart new file mode 100644 index 000000000..3ced43dc5 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/extensions/image_dimension_extension.dart @@ -0,0 +1,19 @@ +import 'package:spotify_sdk/enums/image_dimension_enum.dart'; + +///Extension for formatting the ImageDimension enum to value +///@nodoc +extension ImageDimensionExtension on ImageDimension { + ///maps the value to the specified enum + ///@nodoc + static const values = { + ImageDimension.large: 720, + ImageDimension.medium: 480, + ImageDimension.small: 360, + ImageDimension.xSmall: 240, + ImageDimension.thumbnail: 144, + }; + + /// returns the value + ///@nodoc + int get value => values[this]!; +} diff --git a/local_plugins/spotify_sdk/lib/extensions/podcast_playback_speed_extension.dart b/local_plugins/spotify_sdk/lib/extensions/podcast_playback_speed_extension.dart new file mode 100644 index 000000000..346cca9e5 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/extensions/podcast_playback_speed_extension.dart @@ -0,0 +1,21 @@ +import 'package:spotify_sdk/enums/podcast_playback_speed.dart'; + +///Extension for formatting the PodcastPlaybackSpeed enum to value +///@nodoc +extension PodcastPlaybackSpeedExtension on PodcastPlaybackSpeed { + ///maps the value to the specified enum + ///@nodoc + static const values = { + PodcastPlaybackSpeed.playbackSpeed_50: 50, + PodcastPlaybackSpeed.playbackSpeed_80: 80, + PodcastPlaybackSpeed.playbackSpeed_100: 100, + PodcastPlaybackSpeed.playbackSpeed_120: 120, + PodcastPlaybackSpeed.playbackSpeed_150: 150, + PodcastPlaybackSpeed.playbackSpeed_200: 200, + PodcastPlaybackSpeed.playbackSpeed_300: 300, + }; + + /// returns the value + ///@nodoc + int get value => values[this]!; +} diff --git a/local_plugins/spotify_sdk/lib/logging/custom_log_filter.dart b/local_plugins/spotify_sdk/lib/logging/custom_log_filter.dart new file mode 100644 index 000000000..e8e15a912 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/logging/custom_log_filter.dart @@ -0,0 +1,8 @@ +import 'package:logger/logger.dart'; + +class CustomLogFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) { + return true; + } +} diff --git a/local_plugins/spotify_sdk/lib/models/album.dart b/local_plugins/spotify_sdk/lib/models/album.dart new file mode 100644 index 000000000..e1c8e244e --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/album.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'album.g.dart'; + +@JsonSerializable() +class Album { + Album(this.name, this.uri); + + final String? name; + final String? uri; + + factory Album.fromJson(Map json) => _$AlbumFromJson(json); + + Map toJson() => _$AlbumToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/album.g.dart b/local_plugins/spotify_sdk/lib/models/album.g.dart new file mode 100644 index 000000000..cdbbed1ae --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/album.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'album.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Album _$AlbumFromJson(Map json) => Album( + json['name'] as String?, + json['uri'] as String?, + ); + +Map _$AlbumToJson(Album instance) => { + 'name': instance.name, + 'uri': instance.uri, + }; diff --git a/local_plugins/spotify_sdk/lib/models/artist.dart b/local_plugins/spotify_sdk/lib/models/artist.dart new file mode 100644 index 000000000..f70e97f19 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/artist.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'artist.g.dart'; + +@JsonSerializable() +class Artist { + Artist(this.name, this.uri); + + final String? name; + final String? uri; + + factory Artist.fromJson(Map json) => _$ArtistFromJson(json); + + Map toJson() => _$ArtistToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/artist.g.dart b/local_plugins/spotify_sdk/lib/models/artist.g.dart new file mode 100644 index 000000000..003364e55 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/artist.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'artist.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Artist _$ArtistFromJson(Map json) => Artist( + json['name'] as String?, + json['uri'] as String?, + ); + +Map _$ArtistToJson(Artist instance) => { + 'name': instance.name, + 'uri': instance.uri, + }; diff --git a/local_plugins/spotify_sdk/lib/models/capabilities.dart b/local_plugins/spotify_sdk/lib/models/capabilities.dart new file mode 100644 index 000000000..4652528fb --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/capabilities.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'capabilities.g.dart'; + +@JsonSerializable() +class Capabilities { + Capabilities({ + required this.canPlayOnDemand, + }); + + @JsonKey(name: 'can_play_on_demand') + final bool canPlayOnDemand; + + factory Capabilities.fromJson(Map json) => + _$CapabilitiesFromJson(json); + + Map toJson() => _$CapabilitiesToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/capabilities.g.dart b/local_plugins/spotify_sdk/lib/models/capabilities.g.dart new file mode 100644 index 000000000..b35d53cb6 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/capabilities.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'capabilities.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Capabilities _$CapabilitiesFromJson(Map json) => Capabilities( + canPlayOnDemand: json['can_play_on_demand'] as bool, + ); + +Map _$CapabilitiesToJson(Capabilities instance) => + { + 'can_play_on_demand': instance.canPlayOnDemand, + }; diff --git a/local_plugins/spotify_sdk/lib/models/connection_status.dart b/local_plugins/spotify_sdk/lib/models/connection_status.dart new file mode 100644 index 000000000..2b913b416 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/connection_status.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'connection_status.g.dart'; + +@JsonSerializable() +class ConnectionStatus { + ConnectionStatus( + this.message, + this.errorCode, + this.errorDetails, { + required this.connected, + }); + + @JsonKey(name: 'connected') + final bool connected; + @JsonKey(name: 'message') + final String? message; + @JsonKey(name: 'errorCode') + final String? errorCode; + @JsonKey(name: 'errorDetails') + final String? errorDetails; + + bool hasError() { + return errorCode?.isNotEmpty == true; + } + + factory ConnectionStatus.fromJson(Map json) => + _$ConnectionStatusFromJson(json); + + Map toJson() => _$ConnectionStatusToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/connection_status.g.dart b/local_plugins/spotify_sdk/lib/models/connection_status.g.dart new file mode 100644 index 000000000..0d2f13320 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/connection_status.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connection_status.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ConnectionStatus _$ConnectionStatusFromJson(Map json) => + ConnectionStatus( + json['message'] as String?, + json['errorCode'] as String?, + json['errorDetails'] as String?, + connected: json['connected'] as bool, + ); + +Map _$ConnectionStatusToJson(ConnectionStatus instance) => + { + 'connected': instance.connected, + 'message': instance.message, + 'errorCode': instance.errorCode, + 'errorDetails': instance.errorDetails, + }; diff --git a/local_plugins/spotify_sdk/lib/models/crossfade_state.dart b/local_plugins/spotify_sdk/lib/models/crossfade_state.dart new file mode 100644 index 000000000..a0ba32e5f --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/crossfade_state.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'crossfade_state.g.dart'; + +@JsonSerializable() +class CrossfadeState { + CrossfadeState( + this.duration, { + required this.isEnabled, + }); + + final bool isEnabled; + final int duration; + + factory CrossfadeState.fromJson(Map json) => + _$CrossfadeStateFromJson(json); + + Map toJson() => _$CrossfadeStateToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/crossfade_state.g.dart b/local_plugins/spotify_sdk/lib/models/crossfade_state.g.dart new file mode 100644 index 000000000..74ad4c6f4 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/crossfade_state.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'crossfade_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CrossfadeState _$CrossfadeStateFromJson(Map json) => + CrossfadeState( + json['duration'] as int, + isEnabled: json['isEnabled'] as bool, + ); + +Map _$CrossfadeStateToJson(CrossfadeState instance) => + { + 'isEnabled': instance.isEnabled, + 'duration': instance.duration, + }; diff --git a/local_plugins/spotify_sdk/lib/models/image_uri.dart b/local_plugins/spotify_sdk/lib/models/image_uri.dart new file mode 100644 index 000000000..4523a25ed --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/image_uri.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'image_uri.g.dart'; + +@JsonSerializable() +class ImageUri { + ImageUri(this.raw); + + final String raw; + + factory ImageUri.fromJson(Map json) => + _$ImageUriFromJson(json); + + Map toJson() => _$ImageUriToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/image_uri.g.dart b/local_plugins/spotify_sdk/lib/models/image_uri.g.dart new file mode 100644 index 000000000..6082a9f6b --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/image_uri.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'image_uri.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ImageUri _$ImageUriFromJson(Map json) => ImageUri( + json['raw'] as String, + ); + +Map _$ImageUriToJson(ImageUri instance) => { + 'raw': instance.raw, + }; diff --git a/local_plugins/spotify_sdk/lib/models/library_state.dart b/local_plugins/spotify_sdk/lib/models/library_state.dart new file mode 100644 index 000000000..ff5501534 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/library_state.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'library_state.g.dart'; + +@JsonSerializable() +class LibraryState { + LibraryState( + this.uri, { + required this.isSaved, + required this.canSave, + }); + + @JsonKey(name: 'uri') + final String uri; + @JsonKey(name: 'saved') + final bool isSaved; + @JsonKey(name: 'can_save') + final bool canSave; + + factory LibraryState.fromJson(Map json) => + _$LibraryStateFromJson(json); + + Map toJson() => _$LibraryStateToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/library_state.g.dart b/local_plugins/spotify_sdk/lib/models/library_state.g.dart new file mode 100644 index 000000000..ec1288ab1 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/library_state.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LibraryState _$LibraryStateFromJson(Map json) => LibraryState( + json['uri'] as String, + isSaved: json['saved'] as bool, + canSave: json['can_save'] as bool, + ); + +Map _$LibraryStateToJson(LibraryState instance) => + { + 'uri': instance.uri, + 'saved': instance.isSaved, + 'can_save': instance.canSave, + }; diff --git a/local_plugins/spotify_sdk/lib/models/player_context.dart b/local_plugins/spotify_sdk/lib/models/player_context.dart new file mode 100644 index 000000000..5da6c89a3 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_context.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'player_context.g.dart'; + +@JsonSerializable() +class PlayerContext { + PlayerContext(this.title, this.subtitle, this.type, this.uri); + + final String title; + final String subtitle; + final String type; + final String uri; + + factory PlayerContext.fromJson(Map json) => + _$PlayerContextFromJson(json); + + Map toJson() => _$PlayerContextToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/player_context.g.dart b/local_plugins/spotify_sdk/lib/models/player_context.g.dart new file mode 100644 index 000000000..5e9f989d0 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_context.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_context.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayerContext _$PlayerContextFromJson(Map json) => + PlayerContext( + json['title'] as String, + json['subtitle'] as String, + json['type'] as String, + json['uri'] as String, + ); + +Map _$PlayerContextToJson(PlayerContext instance) => + { + 'title': instance.title, + 'subtitle': instance.subtitle, + 'type': instance.type, + 'uri': instance.uri, + }; diff --git a/local_plugins/spotify_sdk/lib/models/player_options.dart b/local_plugins/spotify_sdk/lib/models/player_options.dart new file mode 100644 index 000000000..f4766b2f5 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_options.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'player_options.g.dart'; + +@JsonSerializable() +class PlayerOptions { + PlayerOptions( + this.repeatMode, { + required this.isShuffling, + }); + + @JsonKey(name: 'shuffle') + final bool isShuffling; + @JsonKey(name: 'repeat') + final RepeatMode repeatMode; + + factory PlayerOptions.fromJson(Map json) => + _$PlayerOptionsFromJson(json); + + Map toJson() => _$PlayerOptionsToJson(this); +} + +enum RepeatMode { + @JsonValue(0) + off, + @JsonValue(1) + track, + @JsonValue(2) + context +} diff --git a/local_plugins/spotify_sdk/lib/models/player_options.g.dart b/local_plugins/spotify_sdk/lib/models/player_options.g.dart new file mode 100644 index 000000000..64fcefb39 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_options.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_options.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayerOptions _$PlayerOptionsFromJson(Map json) => + PlayerOptions( + $enumDecode(_$RepeatModeEnumMap, json['repeat']), + isShuffling: json['shuffle'] as bool, + ); + +Map _$PlayerOptionsToJson(PlayerOptions instance) => + { + 'shuffle': instance.isShuffling, + 'repeat': _$RepeatModeEnumMap[instance.repeatMode]!, + }; + +const _$RepeatModeEnumMap = { + RepeatMode.off: 0, + RepeatMode.track: 1, + RepeatMode.context: 2, +}; diff --git a/local_plugins/spotify_sdk/lib/models/player_restrictions.dart b/local_plugins/spotify_sdk/lib/models/player_restrictions.dart new file mode 100644 index 000000000..bc673ff52 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_restrictions.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'player_restrictions.g.dart'; + +@JsonSerializable() +class PlayerRestrictions { + PlayerRestrictions({ + required this.canSkipNext, + required this.canSkipPrevious, + required this.canRepeatTrack, + required this.canRepeatContext, + required this.canToggleShuffle, + required this.canSeek, + }); + + @JsonKey(name: 'can_skip_next') + final bool canSkipNext; + @JsonKey(name: 'can_skip_prev') + final bool canSkipPrevious; + @JsonKey(name: 'can_repeat_track') + final bool canRepeatTrack; + @JsonKey(name: 'can_repeat_context') + final bool canRepeatContext; + @JsonKey(name: 'can_toggle_shuffle') + final bool canToggleShuffle; + @JsonKey(name: 'can_seek') + final bool canSeek; + + factory PlayerRestrictions.fromJson(Map json) => + _$PlayerRestrictionsFromJson(json); + + Map toJson() => _$PlayerRestrictionsToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/player_restrictions.g.dart b/local_plugins/spotify_sdk/lib/models/player_restrictions.g.dart new file mode 100644 index 000000000..7780e122b --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_restrictions.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_restrictions.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayerRestrictions _$PlayerRestrictionsFromJson(Map json) => + PlayerRestrictions( + canSkipNext: json['can_skip_next'] as bool, + canSkipPrevious: json['can_skip_prev'] as bool, + canRepeatTrack: json['can_repeat_track'] as bool, + canRepeatContext: json['can_repeat_context'] as bool, + canToggleShuffle: json['can_toggle_shuffle'] as bool, + canSeek: json['can_seek'] as bool, + ); + +Map _$PlayerRestrictionsToJson(PlayerRestrictions instance) => + { + 'can_skip_next': instance.canSkipNext, + 'can_skip_prev': instance.canSkipPrevious, + 'can_repeat_track': instance.canRepeatTrack, + 'can_repeat_context': instance.canRepeatContext, + 'can_toggle_shuffle': instance.canToggleShuffle, + 'can_seek': instance.canSeek, + }; diff --git a/local_plugins/spotify_sdk/lib/models/player_state.dart b/local_plugins/spotify_sdk/lib/models/player_state.dart new file mode 100644 index 000000000..547efea3c --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_state.dart @@ -0,0 +1,36 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'player_options.dart'; +import 'player_restrictions.dart'; +import 'track.dart'; + +part 'player_state.g.dart'; + +@JsonSerializable() +class PlayerState { + PlayerState( + this.track, + this.playbackSpeed, + this.playbackPosition, + this.playbackOptions, + this.playbackRestrictions, { + required this.isPaused, + }); + + final Track? track; + @JsonKey(name: 'is_paused') + final bool isPaused; + @JsonKey(name: 'playback_speed') + final double playbackSpeed; + @JsonKey(name: 'playback_position') + final int playbackPosition; + @JsonKey(name: 'playback_options') + final PlayerOptions playbackOptions; + @JsonKey(name: 'playback_restrictions') + final PlayerRestrictions playbackRestrictions; + + factory PlayerState.fromJson(Map json) => + _$PlayerStateFromJson(json); + + Map toJson() => _$PlayerStateToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/player_state.g.dart b/local_plugins/spotify_sdk/lib/models/player_state.g.dart new file mode 100644 index 000000000..d38451065 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/player_state.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayerState _$PlayerStateFromJson(Map json) => PlayerState( + json['track'] == null + ? null + : Track.fromJson(json['track'] as Map), + (json['playback_speed'] as num).toDouble(), + json['playback_position'] as int, + PlayerOptions.fromJson(json['playback_options'] as Map), + PlayerRestrictions.fromJson( + json['playback_restrictions'] as Map), + isPaused: json['is_paused'] as bool, + ); + +Map _$PlayerStateToJson(PlayerState instance) => + { + 'track': instance.track, + 'is_paused': instance.isPaused, + 'playback_speed': instance.playbackSpeed, + 'playback_position': instance.playbackPosition, + 'playback_options': instance.playbackOptions, + 'playback_restrictions': instance.playbackRestrictions, + }; diff --git a/local_plugins/spotify_sdk/lib/models/track.dart b/local_plugins/spotify_sdk/lib/models/track.dart new file mode 100644 index 000000000..8a55af9ea --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/track.dart @@ -0,0 +1,43 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'album.dart'; +import 'artist.dart'; +import 'image_uri.dart'; + +part 'track.g.dart'; + +@JsonSerializable() +class Track { + Track( + this.album, + this.artist, + this.artists, + this.duration, + this.imageUri, + this.name, + this.uri, + this.linkedFromUri, { + required this.isEpisode, + required this.isPodcast, + }); + + final Album album; + final Artist artist; + final List artists; + @JsonKey(name: 'duration_ms') + final int duration; + @JsonKey(name: 'image_id') + final ImageUri imageUri; + @JsonKey(name: 'is_episode') + final bool isEpisode; + @JsonKey(name: 'is_podcast') + final bool isPodcast; + final String name; + final String uri; + @JsonKey(name: 'linked_from_uri') + final String? linkedFromUri; + + factory Track.fromJson(Map json) => _$TrackFromJson(json); + + Map toJson() => _$TrackToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/track.g.dart b/local_plugins/spotify_sdk/lib/models/track.g.dart new file mode 100644 index 000000000..b6c4a6591 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/track.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Track _$TrackFromJson(Map json) => Track( + Album.fromJson(json['album'] as Map), + Artist.fromJson(json['artist'] as Map), + (json['artists'] as List) + .map((e) => Artist.fromJson(e as Map)) + .toList(), + json['duration_ms'] as int, + ImageUri.fromJson(json['image_id'] as Map), + json['name'] as String, + json['uri'] as String, + json['linked_from_uri'] as String?, + isEpisode: json['is_episode'] as bool, + isPodcast: json['is_podcast'] as bool, + ); + +Map _$TrackToJson(Track instance) => { + 'album': instance.album, + 'artist': instance.artist, + 'artists': instance.artists, + 'duration_ms': instance.duration, + 'image_id': instance.imageUri, + 'is_episode': instance.isEpisode, + 'is_podcast': instance.isPodcast, + 'name': instance.name, + 'uri': instance.uri, + 'linked_from_uri': instance.linkedFromUri, + }; diff --git a/local_plugins/spotify_sdk/lib/models/user_status.dart b/local_plugins/spotify_sdk/lib/models/user_status.dart new file mode 100644 index 000000000..2c3727de5 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/user_status.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'user_status.g.dart'; + +@JsonSerializable() +class UserStatus { + UserStatus(this.code, this.shortMessage, this.longMessage); + + final int statusCodeOk = 0; + final int statusCodeNotLoggedIn = 1; + @JsonKey(name: 'code') + final int code; + @JsonKey(name: 'short_text') + final String shortMessage; + @JsonKey(name: 'long_text') + final String longMessage; + + bool isLoggedIn() { + return code == 0; + } + + factory UserStatus.fromJson(Map json) => + _$UserStatusFromJson(json); + + Map toJson() => _$UserStatusToJson(this); +} diff --git a/local_plugins/spotify_sdk/lib/models/user_status.g.dart b/local_plugins/spotify_sdk/lib/models/user_status.g.dart new file mode 100644 index 000000000..2dca58fee --- /dev/null +++ b/local_plugins/spotify_sdk/lib/models/user_status.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_status.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserStatus _$UserStatusFromJson(Map json) => UserStatus( + json['code'] as int, + json['short_text'] as String, + json['long_text'] as String, + ); + +Map _$UserStatusToJson(UserStatus instance) => + { + 'code': instance.code, + 'short_text': instance.shortMessage, + 'long_text': instance.longMessage, + }; diff --git a/local_plugins/spotify_sdk/lib/platform_channels.dart b/local_plugins/spotify_sdk/lib/platform_channels.dart new file mode 100644 index 000000000..419dc6adc --- /dev/null +++ b/local_plugins/spotify_sdk/lib/platform_channels.dart @@ -0,0 +1,161 @@ +/// Holds the names for all event channels that are used in the package +class EventChannels { + /// event channel name for [playerContext] + static const String playerContext = 'player_context_subscription'; + + /// event channel name for [playerState] + static const String playerState = 'player_state_subscription'; + + /// event channel name for [userStatus] + static const String userStatus = 'user_status_subscription'; + + /// event channel name for [capabilities] + static const String capabilities = 'capabilities_subscription'; + + /// event channel name for [connectionStatus] + static const String connectionStatus = 'connection_status_subscription'; +} + +/// Holds the names for all the method channels that are used in the package +class MethodChannels { + /// method channel name for [spotifySdk] + static const String spotifySdk = 'spotify_sdk'; +} + +/// Holds the names for all methods that are used in the package +class MethodNames { + /// method name for [connectToSpotify] + static const String connectToSpotify = 'connectToSpotify'; + + /// method name for [getAccessToken] + static const String getAccessToken = 'getAccessToken'; + + /// method name for [disconnectFromSpotify] + static const String disconnectFromSpotify = 'disconnectFromSpotify'; + + /// method name for [getCrossfadeState] + static const String getCrossfadeState = 'getCrossfadeState'; + + /// method name for [getPlayerState] + static const String getPlayerState = 'getPlayerState'; + + /// method name for [play] + static const String play = 'play'; + + /// method name for [pause] + static const String pause = 'pause'; + + /// method name for [queueTrack] + static const String queueTrack = 'queueTrack'; + + /// method name for [resume] + static const String resume = 'resume'; + + /// method name for [podcastPlaybackSpeed] + static const String setPodcastPlaybackSpeed = 'setPodcastPlaybackSpeed'; + + /// method name for [skipToIndex] + static const String skipToIndex = 'skipToIndex'; + + /// method name for [skipNext] + static const String skipNext = 'skipNext'; + + /// method name for [skipPrevious] + static const String skipPrevious = 'skipPrevious'; + + /// method name for [seekTo] + static const String seekTo = 'seekTo'; + + /// method name for [seekToRelativePosition] + static const String seekToRelativePosition = 'seekToRelativePosition'; + + /// method name for [subscribePlayerContext] + static const String subscribePlayerContext = 'subscribePlayerContext'; + + /// method name for [subscribePlayerState] + static const String subscribePlayerState = 'subscribePlayerState'; + + /// method name for [subscribeConnectionStatus] + static const String subscribeConnectionStatus = 'subscribeConnectionStatus'; + + /// method name for [toggleRepeat] + static const String toggleRepeat = 'toggleRepeat'; + + /// method name for [toggleShuffle] + static const String toggleShuffle = 'toggleShuffle'; + + /// method name for [addToLibrary] + static const String addToLibrary = 'addToLibrary'; + + /// method name for [removeFromLibrary] + static const String removeFromLibrary = 'removeFromLibrary'; + + /// method name for [getCapabilities] + static const String getCapabilities = 'getCapabilities'; + + /// method name for [getLibraryState] + static const String getLibraryState = 'getLibraryState'; + + /// method name for [getImage] + static const String getImage = 'getImage'; + + /// method name for [setShuffle] + static const String setShuffle = 'setShuffle'; + + /// method name for [setRepeatMode] + static const String setRepeatMode = 'setRepeatMode'; + + /// method name for [switchToLocalDevice] + static const String switchToLocalDevice = "switchToLocalDevice"; +} + +/// Holds the names for all parameters that are used in the package +class ParamNames { + /// param name for [clientId] + static const String clientId = 'clientId'; + + /// param name for [redirectUrl] + static const String redirectUrl = 'redirectUrl'; + + /// param name for [scope] + static const String scope = 'scope'; + + /// param name for [playerName] + static const String playerName = 'playerName'; + + /// param name for [spotifyUri] + static const String spotifyUri = 'spotifyUri'; + + /// param name for [imageUri] + static const String imageUri = 'imageUri'; + + /// param name for [imageDimension] + static const String imageDimension = 'imageDimension'; + + /// param name for [positionedMilliseconds] + static const String positionedMilliseconds = 'positionedMilliseconds'; + + /// param name for [relativeMilliseconds] + static const String relativeMilliseconds = 'relativeMilliseconds'; + + /// param name for [accessToken] + static const String accessToken = 'accessToken'; + + /// param name for [asRadio] + static const String asRadio = 'asRadio'; + + /// param name for [shuffle] + static const String shuffle = 'shuffle'; + + /// param name for [repeatMode] + static const String repeatMode = 'repeatMode'; + + /// param name for [podcastPlaybackSpeed] + static const String podcastPlaybackSpeed = 'podcastPlaybackSpeed'; + + /// param name for [uri] + static const String uri = 'uri'; + + /// param name for [trackIndex] + static const String trackIndex = 'trackIndex'; +} diff --git a/local_plugins/spotify_sdk/lib/spotify_sdk.dart b/local_plugins/spotify_sdk/lib/spotify_sdk.dart new file mode 100644 index 000000000..af1a2b6ae --- /dev/null +++ b/local_plugins/spotify_sdk/lib/spotify_sdk.dart @@ -0,0 +1,660 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; + +import 'enums/image_dimension_enum.dart'; +import 'enums/podcast_playback_speed.dart'; +import 'enums/repeat_mode_enum.dart'; +import 'extensions/image_dimension_extension.dart'; +import 'extensions/podcast_playback_speed_extension.dart'; +import 'models/capabilities.dart'; +import 'models/connection_status.dart'; +import 'models/crossfade_state.dart'; +import 'models/image_uri.dart'; +import 'models/library_state.dart'; +import 'models/player_context.dart'; +import 'models/player_state.dart'; +import 'models/user_status.dart'; +import 'platform_channels.dart'; + +export 'package:spotify_sdk/enums/image_dimension_enum.dart'; +export 'package:spotify_sdk/enums/podcast_playback_speed.dart'; +export 'package:spotify_sdk/enums/repeat_mode_enum.dart'; +export 'package:spotify_sdk/extensions/image_dimension_extension.dart'; +export 'package:spotify_sdk/extensions/podcast_playback_speed_extension.dart'; + +/// +/// [SpotifySdk] holds the functionality to connect via spotify remote or +/// get an authToken to control the spotify playback and use the functionality +/// described [here](https://pub.dev/packages/spotify_sdk#usage) +/// +class SpotifySdk { + // method channel + static const MethodChannel _channel = + MethodChannel(MethodChannels.spotifySdk); + + //player event channels + static const EventChannel _playerContextChannel = + EventChannel(EventChannels.playerContext); + static const EventChannel _playerStateChannel = + EventChannel(EventChannels.playerState); + + // user event channels + static const EventChannel _userStatusChannel = + EventChannel(EventChannels.userStatus); + static const EventChannel _capabilitiesChannel = + EventChannel(EventChannels.capabilities); + + // connection status channel + static const EventChannel _connectionStatusChannel = + EventChannel(EventChannels.connectionStatus); + + //logging + static final Logger _logger = Logger( + //filter: CustomLogFilter(), // custom logfilter can be used to have logs in release mode + printer: PrettyPrinter( + methodCount: 2, + errorMethodCount: 8, + lineLength: 120, + colors: true, + printEmojis: true, + ), + ); + + /// Connects to Spotify Remote, returning a [bool] for confirmation + /// + /// Required parameters are the [clientId] and the [redirectUrl] to + /// authenticate with the Spotify Api + /// iOS specific: You can optionally pass an [accessToken] that you have persisted from a previous session. This will prevent redirecting to the Spotify if the token is still valid. It will be ignored on platforms other than iOS. + /// iOS specific: You can optionally pass a [spotifyUri]. A blank string will play the user's last song or pick a random one. It will be ignored on platforms other than iOS. + /// Throws a [PlatformException] if connecting to the remote api failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future connectToSpotifyRemote( + {required String clientId, + required String redirectUrl, + String spotifyUri = '', + bool asRadio = false, + String? scope, + String playerName = 'Spotify SDK', + String? accessToken}) async { + try { + return await _channel.invokeMethod(MethodNames.connectToSpotify, { + ParamNames.clientId: clientId, + ParamNames.redirectUrl: redirectUrl, + ParamNames.playerName: playerName, + ParamNames.accessToken: accessToken, + ParamNames.scope: scope, + ParamNames.spotifyUri: spotifyUri, + ParamNames.asRadio: asRadio, + }); + } on Exception catch (e) { + _logException(MethodNames.connectToSpotify, e); + rethrow; + } + } + + /// Returns an access token as a [String] + /// + /// Required parameters are the [clientId] and the [redirectUrl] to + /// authenticate with the Spotify Api. + /// Also you have to provide a [scope] like + /// "app-remote-control, user-modify-playback-state, playlist-read-private, + /// playlist-modify-public,user-read-currently-playing" + /// See https://developer.spotify.com/documentation/general/guides/scopes/ + /// for more scopes and how to use them + /// The token can be used to communicate with the web api + /// iOS specific: You can optionally pass a [spotifyUri]. A blank string will play the user's last song or pick a random one. It will be ignored on platforms other than iOS. + /// Throws a [PlatformException] if retrieving the access token + /// failed. + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future getAccessToken( + {required String clientId, + required String redirectUrl, + String spotifyUri = '', + bool asRadio = false, + String? scope}) async { + try { + final authorization = + await _channel.invokeMethod(MethodNames.getAccessToken, { + ParamNames.clientId: clientId, + ParamNames.redirectUrl: redirectUrl, + ParamNames.scope: scope, + ParamNames.spotifyUri: spotifyUri, + ParamNames.asRadio: asRadio, + }); + return authorization.toString(); + } on Exception catch (e) { + _logException(MethodNames.getAccessToken, e); + rethrow; + } + } + + @Deprecated('Use [getAccessToken]') + static Future getAuthenticationToken( + {required String clientId, + required String redirectUrl, + String spotifyUri = '', + bool asRadio = false, + String? scope}) => + getAccessToken( + clientId: clientId, + redirectUrl: redirectUrl, + spotifyUri: spotifyUri, + asRadio: asRadio, + scope: scope, + ); + + /// Logs the user out and disconnects the app from the users spotify account + /// + /// Throws a [PlatformException] if disconnect failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future disconnect() async { + try { + return await _channel.invokeMethod(MethodNames.disconnectFromSpotify); + } on Exception catch (e) { + _logException(MethodNames.disconnectFromSpotify, e); + rethrow; + } + } + + /// Gets the current [CrossfadeState] + /// + /// Throws a [PlatformException] getting the crossfadeState failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future getCrossFadeState() async { + try { + var crossfadeStateJson = + await (_channel.invokeMethod(MethodNames.getCrossfadeState)); + if (crossfadeStateJson == null) { + return null; + } + var crossfadeStateMap = + jsonDecode(crossfadeStateJson) as Map; + var crossfadeState = CrossfadeState.fromJson(crossfadeStateMap); + return crossfadeState; + } on Exception catch (e) { + _logException(MethodNames.getCrossfadeState, e); + rethrow; + } + } + + /// Gets the current [PlayerState] + /// + /// Throws a [PlatformException] getting the playerState failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future getPlayerState() async { + try { + var playerStateJson = + await (_channel.invokeMethod(MethodNames.getPlayerState)); + if (playerStateJson == null) { + return null; + } + var playerStateMap = jsonDecode(playerStateJson) as Map; + var playerState = PlayerState.fromJson(playerStateMap); + return playerState; + } on Exception catch (e) { + _logException(MethodNames.getPlayerState, e); + rethrow; + } + } + + /// Queues the given [spotifyUri] + /// + /// The [spotifyUri] can be an artist, album, playlist and track + /// Throws a [PlatformException] if queing failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future queue({required String spotifyUri}) async { + try { + await _channel.invokeMethod( + MethodNames.queueTrack, {ParamNames.spotifyUri: spotifyUri}); + } on Exception catch (e) { + _logException(MethodNames.queueTrack, e); + rethrow; + } + } + + /// Plays the given [spotifyUri] + /// + /// The [spotifyUri] can be an artist, album, playlist and track + /// On iOS set [asRadio] to true to start radio for track URI. Default: false + /// Throws a [PlatformException] if playing failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future play({ + required String spotifyUri, + bool asRadio = false, + }) async { + try { + await _channel.invokeMethod(MethodNames.play, { + ParamNames.spotifyUri: spotifyUri, + ParamNames.asRadio: asRadio, + }); + } on Exception catch (e) { + _logException(MethodNames.play, e); + rethrow; + } + } + + /// Pauses the current playing track + /// + /// Throws a [PlatformException] if pausing failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future pause() async { + try { + await _channel.invokeMethod(MethodNames.pause); + } on Exception catch (e) { + _logException(MethodNames.pause, e); + rethrow; + } + } + + /// Resumes the current paused track + /// + /// Throws a [PlatformException] if resuming failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future resume() async { + try { + await _channel.invokeMethod(MethodNames.resume); + } on Exception catch (e) { + _logException(MethodNames.resume, e); + rethrow; + } + } + + /// Sets the playbackSpeed of the Podcast + /// + /// The podcast playback speed can be controlled via [podcastPlaybackSpeed]. + /// This can only be set if the podcast is played on the local device + /// the same device the app is running on. + /// Throws a [PlatformException] if resuming failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future setPodcastPlaybackSpeed( + {required PodcastPlaybackSpeed podcastPlaybackSpeed}) async { + try { + await _channel.invokeMethod(MethodNames.setPodcastPlaybackSpeed, + {ParamNames.podcastPlaybackSpeed: podcastPlaybackSpeed.value}); + } on Exception catch (e) { + _logException(MethodNames.resume, e); + rethrow; + } + } + + /// Skips to the next track + /// + /// Throws a [PlatformException] if skipping failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future skipNext() async { + try { + await _channel.invokeMethod(MethodNames.skipNext); + } on Exception catch (e) { + _logException(MethodNames.skipNext, e); + rethrow; + } + } + + /// Skips to the previous track + /// + /// Throws a [PlatformException] if skipping failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future skipPrevious() async { + try { + await _channel.invokeMethod(MethodNames.skipPrevious); + } on Exception catch (e) { + _logException(MethodNames.skipPrevious, e); + rethrow; + } + } + + /// Skips to track at specified index in album or playlist + /// + /// The [spotifyUri] can be an album or playlist + /// The [trackIndex] is the index of the track in the playlist to be played + /// Throws a [PlatformException] if skipping failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future skipToIndex({ + required String spotifyUri, + required int trackIndex, + }) async { + try { + await _channel.invokeMethod(MethodNames.skipToIndex, { + ParamNames.spotifyUri: spotifyUri, + ParamNames.trackIndex: trackIndex, + }); + } on Exception catch (e) { + _logException(MethodNames.skipToIndex, e); + rethrow; + } + } + + /// Subscribes to the [PlayerContext] and returns it. + /// + /// Throws a [PlatformException] if this fails + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Stream subscribePlayerContext() { + try { + var playerContextSubscription = + _playerContextChannel.receiveBroadcastStream(); + return playerContextSubscription.asyncMap((playerContextJson) { + var playerContextMap = + jsonDecode(playerContextJson.toString()) as Map; + return PlayerContext.fromJson(playerContextMap); + }); + } on Exception catch (e) { + _logException(MethodNames.subscribePlayerContext, e); + rethrow; + } + } + + /// Subscribes to the [PlayerState] and returns it. + /// + /// Throws a [PlatformException] if this fails + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Stream subscribePlayerState() { + try { + var playerStateSubscription = + _playerStateChannel.receiveBroadcastStream(); + return playerStateSubscription.asyncMap((playerStateJson) { + var playerStateMap = + jsonDecode(playerStateJson.toString()) as Map; + return PlayerState.fromJson(playerStateMap); + }); + } on Exception catch (e) { + _logException(MethodNames.subscribePlayerState, e); + rethrow; + } + } + + /// Subscribes to the [ConnectionStatus] and returns it. + /// + /// Throws a [PlatformException] if this fails + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Stream subscribeConnectionStatus() { + try { + var connectionStatusSubscription = + _connectionStatusChannel.receiveBroadcastStream(); + return connectionStatusSubscription.asyncMap((connectionStatusJson) { + var connectionStatusMap = + jsonDecode(connectionStatusJson.toString()) as Map; + return ConnectionStatus.fromJson(connectionStatusMap); + }); + } on Exception catch (e) { + _logException(MethodNames.subscribeConnectionStatus, e); + rethrow; + } + } + + /// Seeks the current track to the given [positionedMilliseconds] + /// + /// + /// Throws a [PlatformException] if seeking failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future seekTo({required int positionedMilliseconds}) async { + try { + await _channel.invokeMethod(MethodNames.seekTo, + {ParamNames.positionedMilliseconds: positionedMilliseconds}); + } on Exception catch (e) { + _logException(MethodNames.seekTo, e); + rethrow; + } + } + + /// Adds the given [relativeMilliseconds] to the current playback time. + /// + /// This will add [relativeMilliseconds] to the current value of the playback + /// time. This can also be negative to rewind the current track. + /// Throws a [PlatformException] if seeking failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future seekToRelativePosition( + {required int relativeMilliseconds}) async { + try { + await _channel.invokeMethod(MethodNames.seekToRelativePosition, + {ParamNames.relativeMilliseconds: relativeMilliseconds}); + } on Exception catch (e) { + _logException(MethodNames.seekToRelativePosition, e); + rethrow; + } + } + + /// Switch to local device for playback + /// + /// Throws a [PlatformException] if switching to local device failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future switchToLocalDevice() async { + try { + await _channel.invokeMethod(MethodNames.switchToLocalDevice); + } on Exception catch (e) { + _logException(MethodNames.switchToLocalDevice, e); + rethrow; + } + } + + /// Toggles shuffle + /// + /// Throws a [PlatformException] if toggling shuffle failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future toggleShuffle() async { + try { + await _channel.invokeMethod(MethodNames.toggleShuffle); + } on Exception catch (e) { + _logException(MethodNames.toggleShuffle, e); + rethrow; + } + } + + /// Toggles repeat + /// + /// Throws a [PlatformException] if toggling repeat failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future toggleRepeat() async { + try { + await _channel.invokeMethod(MethodNames.toggleRepeat); + } on Exception catch (e) { + _logException(MethodNames.toggleRepeat, e); + rethrow; + } + } + + /// Adds the given [spotifyUri] to the users library + /// + /// Throws a [PlatformException] if adding failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future addToLibrary({required String spotifyUri}) async { + try { + await _channel.invokeMethod( + MethodNames.addToLibrary, {ParamNames.spotifyUri: spotifyUri}); + } on Exception catch (e) { + _logException(MethodNames.addToLibrary, e); + rethrow; + } + } + + /// Removes the given [spotifyUri] from the users library + /// + /// Throws a [PlatformException] if adding failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future removeFromLibrary({required String spotifyUri}) async { + try { + await _channel.invokeMethod( + MethodNames.removeFromLibrary, {ParamNames.spotifyUri: spotifyUri}); + } on Exception catch (e) { + _logException(MethodNames.removeFromLibrary, e); + rethrow; + } + } + + /// Gets the [Capabilities] of the current user + /// + /// Throws a [PlatformException] getting the capability failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future getCapabilities( + {required String spotifyUri}) async { + try { + var capabilitiesJson = + await _channel.invokeMethod(MethodNames.getCapabilities); + + if (capabilitiesJson!.isNotEmpty) { + var capabilitiesMap = + jsonDecode(capabilitiesJson) as Map; + return Capabilities.fromJson(capabilitiesMap); + } + + return null; + } on Exception catch (e) { + _logException(MethodNames.getCapabilities, e); + rethrow; + } + } + + /// Gets the [LibraryState] of the given [spotifyUri] + /// + /// Throws a [PlatformException] when getting the library state failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future getLibraryState( + {required String spotifyUri}) async { + try { + var libraryStateJson = await (_channel.invokeMethod( + MethodNames.getLibraryState, {ParamNames.spotifyUri: spotifyUri})); + if (libraryStateJson == null) { + return null; + } + var libraryStateMap = + jsonDecode(libraryStateJson) as Map; + return LibraryState.fromJson(libraryStateMap); + } on Exception catch (e) { + _logException(MethodNames.getLibraryState, e); + rethrow; + } + } + + /// Subscribes to the [Capabilities] of the current user + /// + /// Throws a [PlatformException] getting the capability failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Stream subscribeCapabilities() { + try { + var capabilitiesSubscription = + _capabilitiesChannel.receiveBroadcastStream(); + return capabilitiesSubscription.asyncMap((capabilitiesJson) { + var capabilitiesMap = + jsonDecode(capabilitiesJson.toString()) as Map; + return Capabilities.fromJson(capabilitiesMap); + }); + } on Exception catch (e) { + _logException(MethodNames.subscribePlayerContext, e); + rethrow; + } + } + + /// Subscribes to the [UserStatus] + /// + /// Throws a [PlatformException] when getting the userStatus failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Stream subscribeUserStatus() { + try { + var userStatusSubscription = _userStatusChannel.receiveBroadcastStream(); + return userStatusSubscription.asyncMap((userStatusJson) { + var userStatusMap = + jsonDecode(userStatusJson.toString()) as Map; + return UserStatus.fromJson(userStatusMap); + }); + } on Exception catch (e) { + _logException(MethodNames.subscribePlayerContext, e); + rethrow; + } + } + + /// Gets an image from a specified [imageUri] + /// + /// The size of the image can be controlled via the [dimension]. + /// If no [dimension] is given the default value of [ImageDimension.medium] + /// will be used + /// Throws a [PlatformException] if adding failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future getImage( + {required ImageUri imageUri, + ImageDimension dimension = ImageDimension.medium}) async { + try { + return _channel.invokeMethod(MethodNames.getImage, { + ParamNames.imageUri: imageUri.raw, + ParamNames.imageDimension: dimension.value + }); + } on Exception catch (e) { + _logException(MethodNames.getImage, e); + rethrow; + } + } + + /// Sets the shuffle mode + /// + /// Set [shuffle] to true or false. + /// Throws a [PlatformException] if adding failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future setShuffle({required bool shuffle}) async { + try { + return _channel.invokeMethod(MethodNames.setShuffle, { + ParamNames.shuffle: shuffle, + }); + } on Exception catch (e) { + _logException(MethodNames.setShuffle, e); + rethrow; + } + } + + /// Sets the repeat mode + /// + /// Set [repeatMode] to a value of [RepeatMode] either [off, track, context]. + /// Throws a [PlatformException] if adding failed + /// Throws a [MissingPluginException] if the method is not implemented on + /// the native platforms. + static Future setRepeatMode({required RepeatMode repeatMode}) async { + try { + return _channel.invokeMethod( + MethodNames.setRepeatMode, {ParamNames.repeatMode: repeatMode.index}); + } on Exception catch (e) { + _logException(MethodNames.setRepeatMode, e); + rethrow; + } + } + + static void _logException(String method, Exception e) { + if (e is PlatformException) { + var message = e.message ?? ''; + message += e.details != null ? '\n${e.details}' : ''; + _logger.e('$method failed with: $message'); + } else if (e is MissingPluginException) { + _logger.e('$method not implemented'); + } else { + _logger.e('$method throws unhandled exception: ${e.toString()}'); + } + } +} diff --git a/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart b/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart new file mode 100644 index 000000000..44bcb6eb5 --- /dev/null +++ b/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart @@ -0,0 +1,1137 @@ +@JS() +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:core'; +import 'dart:developer'; +import 'dart:html'; +import 'dart:math' as math; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:js/js.dart'; +import 'package:js/js_util.dart'; +import 'package:synchronized/synchronized.dart' as synchronized; + +import 'enums/repeat_mode_enum.dart'; +import 'models/album.dart'; +import 'models/artist.dart'; +import 'models/connection_status.dart'; +import 'models/image_uri.dart'; +import 'models/player_context.dart'; +import 'models/player_options.dart' as options; +import 'models/player_restrictions.dart'; +import 'models/player_state.dart'; +import 'models/track.dart'; +import 'platform_channels.dart'; + +export 'package:spotify_sdk/enums/image_dimension_enum.dart'; +export 'package:spotify_sdk/enums/repeat_mode_enum.dart'; +export 'package:spotify_sdk/extensions/image_dimension_extension.dart'; + +/// +/// [SpotifySdkPlugin] is the web implementation of the Spotify SDK plugin. +/// +class SpotifySdkPlugin { + /// authentication token error id + static const String errorAuthenticationTokenError = + 'authenticationTokenError'; + + /// spotify sdk url + static const String spotifySdkUrl = 'https://sdk.scdn.co/spotify-player.js'; + + /// Whether the Spotify SDK is loaded. + bool _sdkLoaded = false; + + /// Future loading the Spotify SDK. + Future? _sdkLoadFuture; + + /// Current Spotify SDK player instance. + Player? _currentPlayer; + + /// Current Spotify auth token. + SpotifyToken? _spotifyToken; + + /// player context event stream controller + final StreamController playerContextEventController; + + /// player state event stream controller + final StreamController playerStateEventController; + + /// player capabilities event stream controller + final StreamController playerCapabilitiesEventController; + + /// user state event stream controller + final StreamController userStateEventController; + + /// connection status event stream controller + final StreamController connectionStatusEventController; + + /// Dio http client + final Dio _dio = Dio(BaseOptions( + baseUrl: 'https://api.spotify.com/v1/me/player', + )); + final Dio _authDio = Dio(BaseOptions()); + + /// Lock for getting the token + final synchronized.Lock _getTokenLock = synchronized.Lock(reentrant: true); + + /// Default scopes that are required for Web SDK to work + static const String defaultScopes = + 'streaming user-read-email user-read-private'; + + static String? tokenSwapURL; + static String? tokenRefreshURL; + + /// constructor + SpotifySdkPlugin( + this.playerContextEventController, + this.playerStateEventController, + this.playerCapabilitiesEventController, + this.userStateEventController, + this.connectionStatusEventController); + + /// registers plugin method channels + static void registerWith(Registrar registrar) { + // method channel + final channel = MethodChannel( + MethodChannels.spotifySdk, const StandardMethodCodec(), registrar); + // event channels + const playerContextEventChannel = + PluginEventChannel(EventChannels.playerContext); + final playerContextEventController = StreamController.broadcast(); + playerContextEventChannel.setController(playerContextEventController); + const playerStateEventChannel = + PluginEventChannel(EventChannels.playerState); + final playerStateEventController = StreamController.broadcast(); + playerStateEventChannel.setController(playerStateEventController); + const playerCapabilitiesEventChannel = + PluginEventChannel(EventChannels.capabilities); + final playerCapabilitiesEventController = StreamController.broadcast(); + playerCapabilitiesEventChannel + .setController(playerCapabilitiesEventController); + const userStatusEventChannel = PluginEventChannel(EventChannels.userStatus); + final userStatusEventController = StreamController.broadcast(); + userStatusEventChannel.setController(userStatusEventController); + const connectionStatusEventChannel = + PluginEventChannel(EventChannels.connectionStatus); + final connectionStatusEventController = StreamController.broadcast(); + connectionStatusEventChannel.setController(connectionStatusEventController); + + final instance = SpotifySdkPlugin( + playerContextEventController, + playerStateEventController, + playerCapabilitiesEventController, + userStatusEventController, + connectionStatusEventController); + + channel.setMethodCallHandler(instance.handleMethodCall); + } + + /// handles method coming through the method channel + Future handleMethodCall(MethodCall call) async { + // check if spotify is loaded + if (_sdkLoaded == false) { + _sdkLoadFuture ??= _initializeSpotify(); + await _sdkLoadFuture; + } + + switch (call.method) { + case MethodNames.connectToSpotify: + if (_currentPlayer != null) { + return true; + } + log('Connecting to Spotify...'); + var clientId = call.arguments[ParamNames.clientId] as String?; + var redirectUrl = call.arguments[ParamNames.redirectUrl] as String?; + var playerName = call.arguments[ParamNames.playerName] as String?; + var scopes = + call.arguments[ParamNames.scope] as String? ?? defaultScopes; + var accessToken = call.arguments[ParamNames.accessToken] as String?; + + // ensure that required arguments are present + if (clientId == null || + clientId.isEmpty || + redirectUrl == null || + redirectUrl.isEmpty) { + throw PlatformException( + message: + 'Client id or redirectUrl are not set or have invalid format', + code: 'Authentication Error'); + } + + // get initial token if not supplied + if (accessToken == null || accessToken.isEmpty) { + await _authorizeSpotify( + clientId: clientId, redirectUrl: redirectUrl, scopes: scopes); + } + + // create player + _currentPlayer = Player(PlayerOptions( + name: playerName, + getOAuthToken: allowInterop((Function callback, t) { + _getSpotifyAuthToken().then((value) { + callback(value); + }); + }))); + + _registerPlayerEvents(_currentPlayer!); + var result = await promiseToFuture(_currentPlayer!.connect()); + if (result == true) { + // wait for the confirmation + num time = 0; + while (_currentPlayer!.deviceID == null) { + await Future.delayed(const Duration(milliseconds: 200)); + time += 200; + if (time > 10000) { + return false; + } + } + return true; + } else { + // disconnected + _onSpotifyDisconnected( + errorCode: 'Initialization Error', + errorDetails: 'Attempt to connect to the Spotify SDK failed'); + return false; + } + case MethodNames.getAccessToken: + var clientId = call.arguments[ParamNames.clientId] as String?; + var redirectUrl = call.arguments[ParamNames.redirectUrl] as String?; + + // ensure that required arguments are present + if (clientId == null || + clientId.isEmpty || + redirectUrl == null || + redirectUrl.isEmpty) { + throw PlatformException( + message: + 'Client id or redirectUrl are not set or have invalid format', + code: 'Authentication Error'); + } + + return await _authorizeSpotify( + clientId: clientId, + redirectUrl: redirectUrl, + scopes: + call.arguments[ParamNames.scope] as String? ?? defaultScopes); + case MethodNames.disconnectFromSpotify: + log('Disconnecting from Spotify...'); + _spotifyToken = null; + if (_currentPlayer == null) { + return true; + } else { + _currentPlayer!.disconnect(); + _onSpotifyDisconnected(); + return true; + } + case MethodNames.play: + await _play(call.arguments[ParamNames.spotifyUri] as String?); + break; + case MethodNames.queueTrack: + await _queue(call.arguments[ParamNames.spotifyUri] as String?); + break; + case MethodNames.setShuffle: + await _setShuffle(call.arguments[ParamNames.shuffle] as bool?); + break; + case MethodNames.setRepeatMode: + await _setRepeatMode( + call.arguments[ParamNames.repeatMode] as RepeatMode?); + break; + case MethodNames.resume: + await promiseToFuture(_currentPlayer?.resume()); + break; + case MethodNames.pause: + await promiseToFuture(_currentPlayer?.pause()); + break; + case MethodNames.skipNext: + await promiseToFuture(_currentPlayer?.nextTrack()); + break; + case MethodNames.skipPrevious: + await promiseToFuture(_currentPlayer?.previousTrack()); + break; + case MethodNames.getPlayerState: + var stateRaw = await promiseToFuture(_currentPlayer?.getCurrentState()) + as WebPlaybackState?; + if (stateRaw == null) return null; + return jsonEncode(toPlayerState(stateRaw)!.toJson()); + default: + throw PlatformException( + code: 'Unimplemented', + details: + "Method '${call.method}' not implemented in web spotify_sdk"); + } + } + + /// Loads the Spotify SDK library. + Future _initializeSpotify() async { + if (_onSpotifyWebPlaybackSDKReady == null) { + log('Loading Spotify SDK...'); + + // link spotify ready function + _onSpotifyWebPlaybackSDKReady = allowInterop(_onSpotifyInitialized); + + // load spotify sdk + querySelector('body')!.children.add(ScriptElement()..src = spotifySdkUrl); + + // wait for initialization + while (_sdkLoaded == false) { + await Future.delayed(const Duration(milliseconds: 200)); + } + + log('Spotify SDK loaded!'); + } else { + // spotify sdk already loaded + log('Reusing loaded Spotify SDK'); + _sdkLoaded = true; + } + } + + /// Registers Spotify event handlers. + void _registerPlayerEvents(Player player) { + // player state + player.addListener('player_state_changed', + allowInterop((WebPlaybackState? state) { + if (state == null) return; + playerStateEventController + .add(jsonEncode(toPlayerState(state)!.toJson())); + playerContextEventController + .add(jsonEncode(toPlayerContext(state)!.toJson())); + })); + + // ready/not ready + player.addListener('ready', allowInterop((WebPlaybackPlayer player) { + log('Spotify SDK ready!'); + _onSpotifyConnected(player.device_id); + })); + player.addListener('not_ready', allowInterop((event) { + _onSpotifyDisconnected( + errorCode: 'Spotify SDK not ready', + errorDetails: 'Spotify SDK is not ready to take requests'); + })); + + // errors + player.addListener('initialization_error', + allowInterop((WebPlaybackError error) { + _onSpotifyDisconnected( + errorCode: 'Initialization Error', errorDetails: error.message); + })); + player.addListener('authentication_error', + allowInterop((WebPlaybackError error) { + // If the error is due to browser security, don't disconnect. + // The user needs to interact with the SDK to trigger media activation. + // https://developer.spotify.com/documentation/web-playback-sdk/quick-start/#mobile-support + if (error.message.contains('Browser prevented autoplay')) { + log('authentication_error: ${error.message}'); + return; + } + _onSpotifyDisconnected( + errorCode: 'Authentication Error', errorDetails: error.message); + })); + player.addListener('account_error', allowInterop((WebPlaybackError error) { + _onSpotifyDisconnected( + errorCode: 'Account Error', errorDetails: error.message); + })); + player.addListener('playback_error', allowInterop((WebPlaybackError error) { + log('playback_error: ${error.message}'); + })); + } + + /// Called when the Spotify SDK is first loaded. + void _onSpotifyInitialized() { + _sdkLoaded = true; + } + + /// Called when the plugin successfully connects to the spotify web sdk. + void _onSpotifyConnected(String deviceId) { + _currentPlayer!.deviceID = deviceId; + + // emit connected event + connectionStatusEventController.add(jsonEncode(ConnectionStatus( + 'Spotify SDK connected', + '', + '', + connected: true, + ).toJson())); + } + + /// Called when the plugin disconnects from the spotify sdk. + void _onSpotifyDisconnected({String? errorCode, String? errorDetails}) { + _unregisterPlayerEvents(_currentPlayer!); + _currentPlayer = null; + + if (errorCode != null) { + // disconnected due to error + log('$errorCode: $errorDetails'); + } + + // emit not connected event + connectionStatusEventController.add(jsonEncode(ConnectionStatus( + 'Spotify SDK disconnected', errorCode ?? '', errorDetails ?? '', + connected: false) + .toJson())); + } + + void _unregisterPlayerEvents(Player player) { + player.removeListener('player_state_changed'); + player.removeListener('ready'); + player.removeListener('not_ready'); + player.removeListener('initialization_error'); + player.removeListener('authentication_error'); + player.removeListener('account_error'); + player.removeListener('playback_error'); + } + + /// Gets the current Spotify token or + /// refreshes the token if it expired. + Future _getSpotifyAuthToken() async { + return await _getTokenLock.synchronized(() async { + if (_spotifyToken?.accessToken != null) { + // attempt to use the previously authorized credentials + if (_spotifyToken!.expiry > + DateTime.now().millisecondsSinceEpoch / 1000) { + // access token valid + return _spotifyToken!.accessToken; + } else { + // access token invalid, refresh it + var newToken = await _refreshSpotifyToken( + _spotifyToken!.clientId, _spotifyToken!.refreshToken); + _spotifyToken = SpotifyToken( + clientId: _spotifyToken!.clientId, + accessToken: newToken['access_token'] as String, + refreshToken: newToken['refresh_token'] as String, + expiry: (DateTime.now().millisecondsSinceEpoch / 1000).round() + + (newToken['expires_in'] as int)); + return _spotifyToken!.accessToken; + } + } else { + throw PlatformException( + message: 'Spotify user not logged in!', + code: 'Authentication Error'); + } + }); + } + + /// Authenticates a new user with Spotify and stores access token. + Future _authorizeSpotify( + {required String clientId, + required String redirectUrl, + required String? scopes}) async { + // creating auth uri + var codeVerifier = _createCodeVerifier(); + var codeChallenge = _createCodeChallenge(codeVerifier); + var state = _createAuthState(); + + var params = { + 'client_id': clientId, + 'redirect_uri': redirectUrl, + 'response_type': 'code', + 'state': state, + 'scope': scopes, + }; + + if (tokenSwapURL == null) { + params['code_challenge_method'] = 'S256'; + params['code_challenge'] = codeChallenge; + } + + final authorizationUri = Uri.https( + 'accounts.spotify.com', + 'authorize', + params, + ); + + // opening auth window + var authPopup = window.open( + authorizationUri.toString(), + 'Spotify Authorization', + ); + String? message; + var sub = window.onMessage.listen(allowInterop((event) { + message = event.data.toString(); + // ensure the message contains auth code + if (!message!.startsWith('?code=')) { + message = null; + } + })); + + // loop and wait for auth + while (authPopup.closed == false && message == null) { + // await response from the window + await Future.delayed(const Duration(milliseconds: 250)); + } + + // error if window closed by user + if (message == null) { + throw PlatformException( + message: 'User closed authentication window', + code: 'Authentication Error'); + } + + // parse the returned parameters + var parsedMessage = Uri.parse(message!); + + // check if state is the same + if (state != parsedMessage.queryParameters['state']) { + throw PlatformException( + message: 'Invalid state', code: 'Authentication Error'); + } + + // check for error + if (parsedMessage.queryParameters['error'] != null || + parsedMessage.queryParameters['code'] == null) { + throw PlatformException( + message: "${parsedMessage.queryParameters['error']}", + code: 'Authentication Error'); + } + + // close auth window + if (authPopup.closed == false) { + authPopup.close(); + } + await sub.cancel(); + + // exchange auth code for access and refresh tokens + dynamic authResponse; + + RequestOptions req; + + if (tokenSwapURL == null) { + // build request to exchange auth code with PKCE for access and refresh tokens + req = RequestOptions( + path: 'https://accounts.spotify.com/api/token', + method: 'POST', + data: { + 'client_id': clientId, + 'grant_type': 'authorization_code', + 'code': parsedMessage.queryParameters['code'], + 'redirect_uri': redirectUrl, + 'code_verifier': codeVerifier + }, + contentType: Headers.formUrlEncodedContentType, + ); + } else { + // or build request to exchange code with token swap + // https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/ + req = RequestOptions( + path: tokenSwapURL!, + method: 'POST', + data: { + 'code': parsedMessage.queryParameters['code'], + 'redirect_uri': redirectUrl, + }, + contentType: Headers.formUrlEncodedContentType, + ); + } + + try { + var res = await _authDio.fetch(req); + authResponse = res.data; + } on DioException catch (e) { + log('Spotify auth error: ${e.response?.data}'); + rethrow; + } + + _spotifyToken = SpotifyToken( + clientId: clientId, + accessToken: authResponse['access_token'] as String, + refreshToken: authResponse['refresh_token'] as String, + expiry: (DateTime.now().millisecondsSinceEpoch / 1000).round() + + (authResponse['expires_in'] as int)); + return _spotifyToken!.accessToken; + } + + /// Refreshes the Spotify access token using the refresh token. + Future _refreshSpotifyToken( + String? clientId, String? refreshToken) async { + RequestOptions req; + if (tokenRefreshURL == null) { + // build request to refresh PKCE for access and refresh tokens + req = RequestOptions( + path: 'https://accounts.spotify.com/api/token', + method: 'POST', + data: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': clientId, + }, + contentType: Headers.formUrlEncodedContentType, + ); + } else { + // or build request to refresh code with token swap + // https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/ + req = RequestOptions( + path: tokenRefreshURL!, + method: 'POST', + data: { + 'refresh_token': refreshToken, + }, + contentType: Headers.formUrlEncodedContentType, + ); + } + + try { + var res = await _authDio.fetch(req); + var d = res.data; + d['refresh_token'] = refreshToken; + return d; + } on DioException catch (e) { + log('Token refresh error: ${e.response?.data}'); + rethrow; + } + } + + /// Creates a code verifier as per + /// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce + String _createCodeVerifier() { + return _createRandomString(127); + } + + /// Creates a code challenge as per + /// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce + String _createCodeChallenge(String codeVerifier) { + return base64Url + .encode(sha256.convert(ascii.encode(codeVerifier)).bytes) + .replaceAll('=', ''); + } + + /// Creates a random string unique to a given authentication session. + String _createAuthState() { + return _createRandomString(64); + } + + /// Creates a cryptographically random string. + String _createRandomString(int length) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + return List.generate( + 128, (i) => chars[math.Random.secure().nextInt(chars.length)]).join(); + } + + /// Starts track playback on the device. + Future _play(String? uri) async { + if (_currentPlayer?.deviceID == null) { + throw PlatformException( + message: 'Spotify player not connected!', code: 'Playback Error'); + } + + await _dio.put( + '/play', + data: { + 'uris': [uri] + }, + queryParameters: {'device_id': _currentPlayer!.deviceID}, + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${await _getSpotifyAuthToken()}' + }, + ), + ); + } + + /// Adds a given track to the playback queue. + Future _queue(String? uri) async { + if (_currentPlayer?.deviceID == null) { + throw PlatformException( + message: 'Spotify player not connected!', code: 'Playback Error'); + } + + await _dio.post( + '/queue', + queryParameters: {'uri': uri, 'device_id': _currentPlayer!.deviceID}, + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${await _getSpotifyAuthToken()}' + }, + ), + ); + } + + /// Sets whether shuffle should be enabled. + Future _setShuffle(bool? shuffleEnabled) async { + if (_currentPlayer?.deviceID == null) { + throw PlatformException( + message: 'Spotify player not connected!', code: 'Set Shuffle Error'); + } + + await _dio.put( + '/shuffle', + queryParameters: { + 'state': shuffleEnabled, + 'device_id': _currentPlayer!.deviceID + }, + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${await _getSpotifyAuthToken()}' + }, + ), + ); + } + + /// Sets the repeat mode. + Future _setRepeatMode(RepeatMode? repeatMode) async { + if (_currentPlayer?.deviceID == null) { + throw PlatformException( + message: 'Spotify player not connected!', + code: 'Set Repeat Mode Error'); + } + + await _dio.put( + '/repeat', + queryParameters: { + 'state': repeatMode.toString().substring(11), + 'device_id': _currentPlayer!.deviceID + }, + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${await _getSpotifyAuthToken()}' + }, + ), + ); + } + + /// Toggles shuffle on the current player. + Future toggleShuffle({bool? state}) async { + if (_currentPlayer?.deviceID == null) { + throw PlatformException( + message: 'Spotify player not connected!', code: 'Playback Error'); + } + + await _dio.put( + '/shuffle', + queryParameters: {'state': state, 'device_id': _currentPlayer!.deviceID}, + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${await _getSpotifyAuthToken()}' + }, + ), + ); + } + + /// Toggles repeat on the current player. + Future toggleRepeat({bool? state}) async { + if (_currentPlayer?.deviceID == null) { + throw PlatformException( + message: 'Spotify player not connected!', code: 'Playback Error'); + } + + await _dio.put( + '/repeat', + queryParameters: {'state': state, 'device_id': _currentPlayer!.deviceID}, + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${await _getSpotifyAuthToken()}' + }, + ), + ); + } + + /// Converts a native WebPlaybackState to the library PlayerState + PlayerState? toPlayerState(WebPlaybackState? state) { + if (state == null) return null; + var trackRaw = state.track_window.current_track; + var albumRaw = trackRaw?.album; + var restrictionsRaw = state.disallows; + var artists = []; + + if (trackRaw != null) { + for (var artist in trackRaw.artists) { + artists.add(Artist(artist.name, artist.uri)); + } + } + + // getting repeat mode + options.RepeatMode repeatMode; + switch (state.repeat_mode) { + case 1: + repeatMode = options.RepeatMode.context; + break; + case 2: + repeatMode = options.RepeatMode.track; + break; + default: + repeatMode = options.RepeatMode.off; + break; + } + + return PlayerState( + trackRaw != null + ? Track( + Album(albumRaw!.name, albumRaw.uri), + artists[0], + artists, + -1, + ImageUri(albumRaw.images[0].url), + trackRaw.name, + trackRaw.uri, + trackRaw.linked_from.uri, + isEpisode: trackRaw.type == 'episode', + isPodcast: trackRaw.type == 'episode', + ) + : null, + 1.0, + state.position, + options.PlayerOptions(repeatMode, isShuffling: state.shuffle), + PlayerRestrictions( + canSkipNext: restrictionsRaw.skipping_next || true, + canSkipPrevious: restrictionsRaw.skipping_prev || true, + canSeek: restrictionsRaw.seeking || true, + canRepeatTrack: true, + canRepeatContext: true, + canToggleShuffle: true), + isPaused: state.paused, + ); + } + + /// Converts a native WebPlaybackState to the library PlayerContext + PlayerContext? toPlayerContext(WebPlaybackState? state) { + if (state == null) return null; + return PlayerContext( + state.context.metadata.title, + state.context.metadata.subtitle, + state.context.metadata.type, + state.context.uri); + } +} + +/// Allows assigning the function onSpotifyWebPlaybackSDKReady +/// to be callable from `window.onSpotifyWebPlaybackSDKReady()` +@JS('onSpotifyWebPlaybackSDKReady') +external set _onSpotifyWebPlaybackSDKReady(void Function()? f); + +/// Allows assigning the function onSpotifyWebPlaybackSDKReady +/// to be callable from `window.onSpotifyWebPlaybackSDKReady()` +@JS('onSpotifyWebPlaybackSDKReady') +external void Function()? get _onSpotifyWebPlaybackSDKReady; + +/// Spotify Player Object +@JS('Spotify.Player') +class Player { + /// Device id of the player. + String? deviceID; + + /// The main constructor for initializing the Web Playback SDK. + /// It should contain an object with the player name, volume and access token. + external Player(PlayerOptions options); + + /// Connects Web Playback SDK instance to Spotify + /// with the credentials provided during initialization. + external dynamic connect(); + + /// Closes the current session that Web Playback SDK has with Spotify. + external void disconnect(); + + /// Create a new event listener in the Web Playback SDK. + external void addListener(String type, Function callback); + + /// Remove an event listener in the Web Playback SDK. + // ignore: non_constant_identifier_names + external void removeListener(String event_name); + + /// Collect metadata on local playback. + external dynamic getCurrentState(); + + /// Rename the Spotify Player device. + /// This is visible across all Spotify Connect devices. + external dynamic setName(String name); + + /// Set the local volume for the Web Playback SDK. + external dynamic setVolume(double volume); + + /// Pause the local playback. + external dynamic pause(); + + /// Resume the local playback. + external dynamic resume(); + + /// Resume/pause the local playback. + external dynamic togglePlay(); + + /// Seek to a position in the current track in local playback. + // ignore: non_constant_identifier_names + external dynamic seek(int position_ms); + + /// Switch to the previous track in local playback. + external dynamic previousTrack(); + + /// Skip to the next track in local playback. + external dynamic nextTrack(); +} + +/// Spotify player options object +@JS() +@anonymous +class PlayerOptions { + // ignore: public_member_api_docs + external String get name; + // ignore: public_member_api_docs + external Function get getOAuthToken; + // ignore: public_member_api_docs + external double get volume; + + // ignore: public_member_api_docs + external factory PlayerOptions( + {String? name, Function? getOAuthToken, double? volume}); +} + +/// Spotify playback object +@JS() +@anonymous +class WebPlaybackPlayer { + // ignore: public_member_api_docs, non_constant_identifier_names + external String get device_id; + // ignore: public_member_api_docs, non_constant_identifier_names + external factory WebPlaybackPlayer({String? device_id}); +} + +/// Spotify playback state object +@JS() +@anonymous +class WebPlaybackState { + // ignore: public_member_api_docs + external WebPlayerContext get context; + // ignore: public_member_api_docs + external WebPlayerDisallows get disallows; + // ignore: public_member_api_docs + external bool get paused; + // ignore: public_member_api_docs + external int get position; + // ignore: public_member_api_docs, non_constant_identifier_names + external int get repeat_mode; + // ignore: public_member_api_docs + external bool get shuffle; + // ignore: public_member_api_docs, non_constant_identifier_names + external WebPlayerTrackWindow get track_window; + + // ignore: public_member_api_docs + external factory WebPlaybackState( + {WebPlayerContext? context, + WebPlayerDisallows? disallows, + bool? paysed, + int? position, + // ignore: non_constant_identifier_names + int? repeat_mode, + bool? shuffle, + // ignore: non_constant_identifier_names + WebPlayerTrackWindow? track_window}); +} + +/// Spotify player context object +@JS() +@anonymous +class WebPlayerContext { + // ignore: public_member_api_docs + external String get uri; + // ignore: public_member_api_docs + external WebPlayerContextMetadata get metadata; + + // ignore: public_member_api_docs + external factory WebPlayerContext( + {String? uri, WebPlayerContextMetadata? metadata}); +} + +/// Spotify player context metadata object +@JS() +@anonymous +class WebPlayerContextMetadata { + // ignore: public_member_api_docs + external String get title; + // ignore: public_member_api_docs + external String get subtitle; + // ignore: public_member_api_docs + external String get type; + + // ignore: public_member_api_docs + external factory WebPlayerContextMetadata( + {String? title, String? subtitle, String? type}); +} + +/// Spotify player disallows object +@JS() +@anonymous +class WebPlayerDisallows { + // ignore: public_member_api_docs + external bool get pausing; + // ignore: public_member_api_docs, non_constant_identifier_names + external bool get peeking_next; + // ignore: public_member_api_docs, non_constant_identifier_names + external bool get peeking_prev; + // ignore: public_member_api_docs + external bool get resuming; + // ignore: public_member_api_docs + external bool get seeking; + // ignore: public_member_api_docs, non_constant_identifier_names + external bool get skipping_next; + // ignore: public_member_api_docs, non_constant_identifier_names + external bool get skipping_prev; + + // ignore: public_member_api_docs + external factory WebPlayerDisallows( + {bool? pausing, + // ignore: non_constant_identifier_names + bool? peeking_next, + // ignore: non_constant_identifier_names + bool? peeking_prev, + bool? resuming, + bool? seeking, + // ignore: non_constant_identifier_names + bool? skipping_next, + // ignore: non_constant_identifier_names + bool? skipping_prev}); +} + +/// Spotify player track window object +@JS() +@anonymous +class WebPlayerTrackWindow { + // ignore: public_member_api_docs, non_constant_identifier_names + external WebPlaybackTrack? get current_track; + // ignore: public_member_api_docs, non_constant_identifier_names + external List? get previous_tracks; + // ignore: public_member_api_docs, non_constant_identifier_names + external List? get next_tracks; + + // ignore: public_member_api_docs + external factory WebPlayerTrackWindow( + // ignore: non_constant_identifier_names + {WebPlaybackTrack? current_track, + // ignore: non_constant_identifier_names + List? previous_tracks, + // ignore: non_constant_identifier_names + List? next_tracks}); +} + +/// Spotify playback track object +@JS() +@anonymous +class WebPlaybackTrack { + // ignore: public_member_api_docs + external String get uri; + // ignore: public_member_api_docs + external String get id; + // ignore: public_member_api_docs + external String get type; + // ignore: public_member_api_docs, non_constant_identifier_names + external String get media_type; + // ignore: public_member_api_docs + external String get name; + // ignore: public_member_api_docs, non_constant_identifier_names + external bool get is_playable; + // ignore: public_member_api_docs + external WebPlaybackAlbum get album; + // ignore: public_member_api_docs + external List get artists; + // ignore: public_member_api_docs + // ignore: non_constant_identifier_names + external WebLinkedFrom get linked_from; + + // ignore: public_member_api_docs + external factory WebPlaybackTrack( + {String? uri, + String? id, + String? type, + // ignore: non_constant_identifier_names + String? media_type, + String? name, + // ignore: non_constant_identifier_names + bool? is_playable, + WebPlaybackAlbum? album, + List? artists, + // ignore: non_constant_identifier_names + WebLinkedFrom? linked_from}); +} + +/// Spotify playback album object +@JS() +@anonymous +class WebPlaybackAlbum { + // ignore: public_member_api_docs + external String get uri; + // ignore: public_member_api_docs + external String get name; + // ignore: public_member_api_docs + external List get images; + + // ignore: public_member_api_docs + external factory WebPlaybackAlbum( + {String? uri, String? name, List? images}); +} + +/// Spotify playback album object +@JS() +@anonymous +class WebLinkedFrom { + // ignore: public_member_api_docs + external String get uri; + // ignore: public_member_api_docs + external String get id; + + // ignore: public_member_api_docs + external factory WebLinkedFrom({String? uri, String? id}); +} + +/// Spotify artist object +@JS() +@anonymous +class WebPlaybackArtist { + // ignore: public_member_api_docs + external String get uri; + // ignore: public_member_api_docs + external String get name; + + // ignore: public_member_api_docs + external factory WebPlaybackArtist({String? uri, String? name}); +} + +/// Spotify album image object +@JS() +@anonymous +class WebPlaybackAlbumImage { + // ignore: public_member_api_docs + external String get url; + + // ignore: public_member_api_docs + external factory WebPlaybackAlbumImage({String? url}); +} + +/// Spotify playback error object +@JS() +@anonymous +class WebPlaybackError { + // ignore: public_member_api_docs + external String get message; + + // ignore: public_member_api_docs + external factory WebPlaybackError({String? message}); +} + +/// Spotify token object. +class SpotifyToken { + /// Currently used client id. + final String clientId; + + /// Access token data. + final String accessToken; + + /// Refresh token data. + final String refreshToken; + + /// Token expiry time in unix seconds. + final int expiry; + + // ignore: public_member_api_docs + SpotifyToken( + {required this.clientId, + required this.accessToken, + required this.refreshToken, + required this.expiry}); +} diff --git a/local_plugins/spotify_sdk/pubspec.yaml b/local_plugins/spotify_sdk/pubspec.yaml new file mode 100644 index 000000000..0a14ec222 --- /dev/null +++ b/local_plugins/spotify_sdk/pubspec.yaml @@ -0,0 +1,82 @@ +name: spotify_sdk +description: A flutter plugin that let's you communicate with the spotify sdk and auth lib +version: 3.0.2 +homepage: https://github.com/brim-borium/spotify_sdk +issue_tracker: https://github.com/brim-borium/spotify_sdk/issues + +environment: + sdk: ^3.5.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + logger: ^2.4.0 + dio: ^5.7.0 + json_annotation: ^4.9.0 + js: ">=0.6.7 <0.8.0" + crypto: ^3.0.5 + synchronized: ^3.3.0+3 + http: ^1.2.2 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.13 + json_serializable: ^6.8.0 + flutter_lints: ^5.0.0 + + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # This section identifies this Flutter project as a plugin project. + # The androidPackage and pluginClass identifiers should not ordinarily + # be modified. They are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: de.minimalme.spotify_sdk + pluginClass: SpotifySdkPlugin + ios: + package: de.minimalme.spotify_sdk + pluginClass: SpotifySdkPlugin + web: + pluginClass: SpotifySdkPlugin + fileName: spotify_sdk_web.dart + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/local_plugins/spotify_sdk/test/spotify_sdk_test.dart b/local_plugins/spotify_sdk/test/spotify_sdk_test.dart new file mode 100644 index 000000000..656758738 --- /dev/null +++ b/local_plugins/spotify_sdk/test/spotify_sdk_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotify_sdk/spotify_sdk.dart'; + +void main() { + const channel = MethodChannel('spotify_sdk'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return true; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('connectToSpotify', () async { + expect( + await SpotifySdk.connectToSpotifyRemote( + clientId: 'null', redirectUrl: 'null'), + true); + }); +} diff --git a/local_plugins/volcano_speech/LICENSE b/local_plugins/volcano_speech/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/volcano_speech/README.md b/local_plugins/volcano_speech/README.md new file mode 100644 index 000000000..572317b57 --- /dev/null +++ b/local_plugins/volcano_speech/README.md @@ -0,0 +1,206 @@ +# 火山引擎语音服务插件 + +基于火山引擎语音SDK封装的Flutter插件,支持Android平台。 + +## 功能 +- 语音合成(TTS) + - 支持在线/离线/混合合成模式 + - 支持SSML格式文本 + - 支持情感合成和情感预测 + - 支持音量、语速、音高等多种参数调节 + - 复刻音色支持 + - 离线资源管理 +- 语音识别(ASR) + - 一次性识别 + - 连续识别 + - 长按识别 + - 热词优化 + - 多语言支持 + +## 使用说明 + +### TTS 语音合成 + +#### 基础用法 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initialize(appId: 'YOUR_APP_ID', apiKey: 'YOUR_API_KEY'); + +// 设置音量、语速等参数 +await VolcanoSpeech.setSpeechParams( + rate: 0, // 语速: -500到500,0为正常速度 + volume: 100, // 音量: 0到100,默认100 + pitch: 0, // 音高: -500到500,0为正常音高 + silenceDuration: 500, // 静音段时长: 毫秒 +); + +// 设置音色 +await VolcanoSpeech.setVoice( + voiceName: 'zh_female_qingxin', + voiceType: 'qingxin' +); + +// 开始合成并播放 +await VolcanoSpeech.speakText(text: '这是一段测试文本'); + +// 暂停播放 +await VolcanoSpeech.pausePlayback(); + +// 继续播放 +await VolcanoSpeech.resumePlayback(); + +// 停止播放 +await VolcanoSpeech.stopSpeaking(); + +// 销毁引擎 +await VolcanoSpeech.dispose(); +``` + +#### 进阶用法 +```dart +// 设置工作模式 +await VolcanoSpeech.setWorkMode(mode: TtsWorkMode.alternate); // 先在线,断网时切换离线 + +// 设置离线发音人 +await VolcanoSpeech.setOfflineVoice( + voiceName: 'xifei', + voiceType: 'qingxin' +); + +// 下载离线资源 +await VolcanoSpeech.downloadOfflineResource( + voiceTypes: ['qingxin', 'zhenjiang'], + languages: ['zh-CN'] +); + +// 使用SSML格式文本 +await VolcanoSpeech.setTextType(type: TtsTextType.ssml); +await VolcanoSpeech.speakText( + text: '这是一段2023-10-01的语音合成' +); + +// 设置情感 +await VolcanoSpeech.setEmotion(emotion: 'happy'); + +// 启用情感预测 +await VolcanoSpeech.setEnableEmotionPredict(enable: true); + +// 启用服务端缓存 +await VolcanoSpeech.setEnableCache(enable: true); + +// 监听TTS进度事件 +VolcanoSpeech.ttsProgressEvents.listen((event) { + print('播放进度: ${(event.progress * 100).toStringAsFixed(1)}%'); +}); + +// 复刻音色支持 +await VolcanoSpeech.setEnableVoiceClone( + enable: true, + backendCluster: 'your_cluster_name' +); +``` + +### ASR 语音识别 +#### 一次性识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', + supportedLanguages: ['zh-CN'], +); + +// 设置识别语言 +await VolcanoSpeech.setAsrLanguage(language: 'zh-CN'); + +// 设置热词(可选) +await VolcanoSpeech.setAsrHotWords( + hotWords: '{"hotwords":[{"word":"火山引擎","scale":2.0}]}', +); + +// 开始一次性识别 +try { + final result = await VolcanoSpeech.recognizeOnce(); + print('识别结果: ${result['text']}, 语言: ${result['language']}'); +} catch (e) { + print('识别出错: $e'); +} + +// 销毁引擎 +await VolcanoSpeech.disposeAsr(); +``` + +#### 连续识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', +); + +// 监听识别事件 +VolcanoSpeech.asrEvents.listen((event) { + switch (event.type) { + case AsrEventType.sessionStarted: + print('识别会话开始'); + break; + case AsrEventType.sessionStopped: + print('识别会话结束'); + break; + case AsrEventType.recognizing: + print('正在识别: ${event.text}'); + break; + case AsrEventType.result: + print('识别结果: ${event.text}'); + break; + case AsrEventType.volumeChanged: + print('音量: ${event.volume}'); + break; + case AsrEventType.error: + print('识别错误: ${event.errorMessage}'); + break; + } +}); + +// 开始连续识别 +await VolcanoSpeech.startContinuousRecognition(); + +// 检查是否正在识别 +final isActive = await VolcanoSpeech.isContinuousRecognitionActive(); +print('是否正在识别: $isActive'); + +// 停止连续识别 +await VolcanoSpeech.stopContinuousRecognition(); +``` + +#### 长按识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', +); + +// 监听识别事件 +VolcanoSpeech.asrEvents.listen((event) { + // 处理事件... +}); + +// 用户按下按钮时开始识别 +onPressed: () async { + await VolcanoSpeech.startListening(); +}, + +// 用户释放按钮时停止识别 +onReleased: () async { + await VolcanoSpeech.stopListening(); +}, +``` \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/build.gradle.kts b/local_plugins/volcano_speech/android/build.gradle.kts new file mode 100644 index 000000000..713a21a59 --- /dev/null +++ b/local_plugins/volcano_speech/android/build.gradle.kts @@ -0,0 +1,64 @@ +import com.android.build.gradle.LibraryExtension + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:7.3.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0") + } +} + +allprojects { + repositories { + google() + mavenCentral() + + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +// 配置android扩展 +configure { + namespace = "com.yunqiinnovation.volcano_speech" + compileSdkVersion(33) + + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + sourceSets { + getByName("main") { + manifest.srcFile("src/main/AndroidManifest.xml") + java.srcDirs("src/main/kotlin") + } + } + + // 添加lint选项 + lintOptions { + isCheckReleaseBuilds = false + } +} + +// 显式设置Kotlin JVM目标版本 +tasks.withType { + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + // 添加火山引擎语音合成SDK + implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/settings.gradle.kts b/local_plugins/volcano_speech/android/settings.gradle.kts new file mode 100644 index 000000000..7f5342866 --- /dev/null +++ b/local_plugins/volcano_speech/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "volcano_speech" \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml b/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..338e8293c --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt new file mode 100644 index 000000000..d9b63819a --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt @@ -0,0 +1,562 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.bytedance.speech.speechengine.SpeechEngine +import com.bytedance.speech.speechengine.SpeechEngineDefines +import com.bytedance.speech.speechengine.SpeechEngineGenerator +import com.yunqiinnovation.volcano_speech.utils.FileLogger +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * 火山语音识别帮助类 (大模型版本) + */ +class VolcanoAsrHelper(private val context: Context) { + private val TAG = "VolcanoAsrHelper" + private val mainHandler = Handler(Looper.getMainLooper()) + + // 语音引擎相关 + private var engine: SpeechEngine? = null + private var engineHandler: Long = -1 + private var isInitialized = false + + // 当前回调 + private var currentAsrCallback: ASRCallback? = null + private var currentContinuousCallback: ASRContinuousCallback? = null + + // 识别状态 + private var isContinuousRecognitionActive = false + + // 配置参数 + private var language = "zh-CN" + private var enableVolume = false + private var showUtterances = false + + /** + * ASR一次性识别回调 + */ + interface ASRCallback { + fun onSuccess(text: String) + fun onError(error: String) + } + + /** + * ASR连续识别回调 + */ + interface ASRContinuousCallback { + fun onResult(text: String) + fun onRecognizing(text: String) + fun onSessionStarted() + fun onSessionStopped() + fun onVolumeChanged(volume: Int) + fun onError(error: String) + } + + /** + * 初始化语音识别引擎 + */ + fun initialize(appId: String, token: String, resourceId: String = "volc.bigasr.sauc.duration"): Boolean { + if (isInitialized) { + FileLogger.i(TAG, "引擎已经初始化") + return true + } + + try { + // 准备环境 + try { + SpeechEngineGenerator.PrepareEnvironment(context, null) + } catch (e: Exception) { + FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") + // 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类 + } + + // 创建引擎 + engine = SpeechEngineGenerator.getInstance() + engineHandler = engine?.createEngine() ?: -1 + + if (engineHandler == -1L) { + FileLogger.e(TAG, "创建引擎失败") + return false + } + + // 设置上下文 + engine?.setContext(context) + + // 设置引擎类型为ASR + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.ASR_ENGINE) + + // 设置日志级别 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) + + // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") + + // 设置鉴权信息 - 大模型版本不需要Bearer前缀 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) + + // 设置资源ID - 大模型必需 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + + // 设置协议类型为Seed - 大模型必需 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) + + // 设置网络配置 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, "wss://openspeech.bytedance.com") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, "/api/v3/sauc/bigmodel") + + // 设置超时时间 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CONN_TIMEOUT_INT, 12000) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RECV_TIMEOUT_INT, 8000) + + // 设置音频来源为录音机 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, SpeechEngineDefines.RECORDER_TYPE_RECORDER) + + // 设置最大录音时长 (默认60秒) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, 60000) + + // 设置回声消除 (用于ASR识别时不会收到TTS的声音) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_PRESET_INT, SpeechEngineDefines.RECORDER_PRESET_VOICE_COMMUNICATION) + + // 初始化引擎 + val result = engine?.initEngine(engineHandler) + isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR + + if (isInitialized) { + FileLogger.i(TAG, "引擎初始化成功") + + // 设置回调监听 + engine?.setListener(object : SpeechEngine.SpeechListener { + override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { + val stdData = String(data) + handleEngineEvent(type, stdData) + } + }) + + return true + } else { + // 错误码说明: + // -1:通用错误 + // -2:无效参数 + // -3:无效状态 + // -4:无效引擎 + // -5:操作失败 + // -6:缺少配置 + // -7:参数无效 + // -8:超时 + // -9:资源不足 + FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化异常: ${e.message}", e) + return false + } + } + + /** + * 设置语言 + */ + fun setLanguage(language: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.language = language + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置语言失败: ${e.message}", e) + return false + } + } + + /** + * 设置热词 + */ + fun setHotWords(hotWordsId: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + if (hotWordsId.isNotEmpty()) { + // 大模型ASR需要通过请求参数设置热词 + val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + } + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置热词失败: ${e.message}", e) + return false + } + } + + /** + * 设置是否返回音量 + */ + fun setEnableVolume(enable: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.enableVolume = enable + engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_GET_VOLUME_BOOL, enable) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置音量返回失败: ${e.message}", e) + return false + } + } + + /** + * 设置是否显示语音停顿、分句、分词信息 + */ + fun setShowUtterances(show: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.showUtterances = show + engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTER_BOOL, show) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置语音信息显示失败: ${e.message}", e) + return false + } + } + + /** + * 设置VAD切句参数 + */ + fun setVadParams(forceToSpeechTime: Int, endWindowSize: Int): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val reqParams = "{\"force_to_speech_time\":$forceToSpeechTime, \"end_window_size\":$endWindowSize}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置VAD参数失败: ${e.message}", e) + return false + } + } + + /** + * 设置纠错词表 + */ + fun setCorrectWords(correctWordsJson: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val reqParams = "{\"context\": \"{\\\"correct_words\\\": $correctWordsJson}\"}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置纠错词表失败: ${e.message}", e) + return false + } + } + + /** + * 一次性识别 + */ + fun recognizeOnce(callback: ASRCallback): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.e(TAG, "当前正在连续识别中") + return false + } + + try { + this.currentAsrCallback = callback + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始识别 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动识别失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "识别异常: ${e.message}", e) + return false + } + } + + /** + * 停止一次性识别 + */ + fun stopRecognize(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + // 告知引擎音频输入完成 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "停止识别失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "停止识别异常: ${e.message}", e) + return false + } + } + + /** + * 开始连续识别 + */ + fun startContinuousRecognition(callback: ASRContinuousCallback): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.e(TAG, "当前已经在连续识别中") + return false + } + + try { + this.currentContinuousCallback = callback + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始识别 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动连续识别失败: $ret") + return false + } + + isContinuousRecognitionActive = true + return true + } catch (e: Exception) { + FileLogger.e(TAG, "连续识别异常: ${e.message}", e) + return false + } + } + + /** + * 停止连续识别 + */ + fun stopContinuousRecognition(): Boolean { + if (!isInitialized || !isContinuousRecognitionActive) { + FileLogger.e(TAG, "引擎未初始化或未在连续识别中") + return false + } + + try { + // 停止引擎 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "停止连续识别失败: $ret") + return false + } + + isContinuousRecognitionActive = false + return true + } catch (e: Exception) { + FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e) + return false + } + } + + /** + * 判断是否在连续识别中 + */ + fun isContinuousRecognitionActive(): Boolean { + return isContinuousRecognitionActive + } + + /** + * 释放资源 + */ + fun release() { + if (!isInitialized) { + return + } + + try { + // 停止引擎 + if (isContinuousRecognitionActive) { + stopContinuousRecognition() + } else { + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + } + + // 销毁引擎 + engine?.destroyEngine(engineHandler) + engineHandler = -1 + engine = null + isInitialized = false + + FileLogger.i(TAG, "引擎已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) + } + } + + /** + * 处理引擎事件 + */ + private fun handleEngineEvent(type: Int, data: String) { + when (type) { + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { + // 引擎启动成功 + mainHandler.post { + currentContinuousCallback?.onSessionStarted() + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { + // 引擎停止 + isContinuousRecognitionActive = false + mainHandler.post { + currentContinuousCallback?.onSessionStopped() + } + } + + SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { + // 中间识别结果 + try { + val json = JSONObject(data) + if (!json.has("result")) { + return + } + + val resultArray = json.getJSONArray("result") + if (resultArray.length() > 0) { + val result = resultArray.getJSONObject(0) + val text = result.optString("text", "") + + if (text.isNotEmpty()) { + mainHandler.post { + currentContinuousCallback?.onRecognizing(text) + } + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析中间识别结果异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { + // 最终识别结果 + try { + val json = JSONObject(data) + if (!json.has("result")) { + return + } + + val resultArray = json.getJSONArray("result") + if (resultArray.length() > 0) { + val result = resultArray.getJSONObject(0) + val text = result.optString("text", "") + + if (text.isNotEmpty()) { + mainHandler.post { + if (isContinuousRecognitionActive) { + currentContinuousCallback?.onResult(text) + } else { + currentAsrCallback?.onSuccess(text) + } + } + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析最终识别结果异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> { + // 音量回调 + if (enableVolume && isContinuousRecognitionActive) { + try { + val volume = (data.toFloat() * 100).toInt() + mainHandler.post { + currentContinuousCallback?.onVolumeChanged(volume) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析音量数据异常: ${e.message}", e) + } + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { + // 错误信息 + try { + val json = JSONObject(data) + val errCode = json.optInt("err_code", -1) + val errMsg = json.optString("err_msg", "未知错误") + + FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") + + mainHandler.post { + if (isContinuousRecognitionActive) { + currentContinuousCallback?.onError(errMsg) + isContinuousRecognitionActive = false + } else { + currentAsrCallback?.onError(errMsg) + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) + } + } + } + } + + /** + * 获取错误描述 + */ + private fun getErrorDescription(errorCode: Int?): String { + return when (errorCode) { + -1 -> "通用错误" + -2 -> "无效参数,检查appId、token、resourceId等参数是否正确" + -3 -> "无效状态,引擎状态异常" + -4 -> "无效引擎句柄" + -5 -> "操作失败,可能网络连接问题" + -6 -> "缺少配置参数" + -7 -> "参数设置无效,请检查appId、token等参数是否有效" + -8 -> "操作超时" + -9 -> "资源不足" + else -> "未知错误: $errorCode" + } + } +} diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt new file mode 100644 index 000000000..bb6f1e452 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt @@ -0,0 +1,445 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.annotation.NonNull +import com.yunqiinnovation.volcano_speech.utils.FileLogger + +import io.flutter.embedding.engine.plugins.FlutterPlugin +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 io.flutter.plugin.common.EventChannel + +/** VolcanoSpeechPlugin */ +class VolcanoSpeechPlugin: FlutterPlugin { + private val TAG = "VolcanoSpeechPlugin" + private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) + + // ASR相关 + private lateinit var asrChannel: MethodChannel + private lateinit var asrEventChannel: EventChannel + private var asrEventSink: EventChannel.EventSink? = null + + // TTS相关 + private lateinit var ttsChannel: MethodChannel + private lateinit var ttsEventChannel: EventChannel + private var ttsEventSink: EventChannel.EventSink? = null + + // TTS帮助类 + private lateinit var volcanoTtsHelper: VolcanoTtsHelper + + // ASR帮助类 + private lateinit var volcanoAsrHelper: VolcanoAsrHelper + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + + // 初始化ASR通道 + asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr") + asrChannel.setMethodCallHandler(AsrMethodHandler()) + + // 初始化TTS通道 + ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts") + ttsChannel.setMethodCallHandler(TtsMethodHandler()) + + // 初始化ASR事件通道 + asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr_events") + asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + asrEventSink = events + } + + override fun onCancel(arguments: Any?) { + asrEventSink = null + } + }) + + // 初始化TTS事件通道 + ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts_events") + ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ttsEventSink = events + } + + override fun onCancel(arguments: Any?) { + ttsEventSink = null + } + }) + + // 初始化TTS帮助类 + volcanoTtsHelper = VolcanoTtsHelper(context) + + // 初始化ASR帮助类 + volcanoAsrHelper = VolcanoAsrHelper(context) + } + + // 发送ASR事件 + private fun sendAsrEvent(event: Map) { + FileLogger.d(TAG, "发送ASR事件: $event") + if (asrEventSink == null) { + FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + asrEventSink?.success(event) + FileLogger.d(TAG, "ASR事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") + } + } + } + + // 发送TTS事件 + private fun sendTtsEvent(event: Map) { + FileLogger.d(TAG, "发送TTS事件: $event") + if (ttsEventSink == null) { + FileLogger.w(TAG, "无法发送TTS事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + ttsEventSink?.success(event) + FileLogger.d(TAG, "TTS事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送TTS事件失败: ${e.message}") + } + } + } + + // ASR方法处理器 + inner class AsrMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "initialize" -> { + val appId = call.argument("appId") ?: "" + val apiKey = call.argument("apiKey") ?: "" + val resourceId = call.argument("resourceId") ?: "" + + if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId、apiKey和resourceId不能为空", null) + return + } + + val success = volcanoAsrHelper.initialize(appId, apiKey, resourceId) + result.success(success) + } + "setLanguage" -> { + val language = call.argument("language") ?: "zh-CN" + result.success(volcanoAsrHelper.setLanguage(language)) + } + "setHotWords" -> { + val hotWordsId = call.argument("hotWordsId") ?: "" + result.success(volcanoAsrHelper.setHotWords(hotWordsId)) + } + "setVadParams" -> { + val forceToSpeechTime = call.argument("forceToSpeechTime") ?: 0 + val endWindowSize = call.argument("endWindowSize") ?: 800 + result.success(volcanoAsrHelper.setVadParams(forceToSpeechTime, endWindowSize)) + } + "setCorrectWords" -> { + val correctWordsJson = call.argument("correctWordsJson") ?: "{}" + result.success(volcanoAsrHelper.setCorrectWords(correctWordsJson)) + } + "setEnableVolume" -> { + val enable = call.argument("enable") ?: false + result.success(volcanoAsrHelper.setEnableVolume(enable)) + } + "setShowUtterances" -> { + val enable = call.argument("enable") ?: false + result.success(volcanoAsrHelper.setShowUtterances(enable)) + } + "recognizeOnce" -> { + // 确保当前不在连续识别中 + if (volcanoAsrHelper.isContinuousRecognitionActive()) { + result.error("ASR_BUSY", "当前正在连续识别中", null) + return + } + + volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.ASRCallback { + override fun onSuccess(text: String) { + mainHandler.post { + result.success(mapOf( + "text" to text + )) + } + } + + override fun onError(error: String) { + mainHandler.post { + result.error("ASR_ERROR", error, null) + } + } + }) + } + "startContinuousRecognition" -> { + // 确保事件通道已准备好 + if (asrEventSink == null) { + result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) + return + } + + val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ASRContinuousCallback { + override fun onResult(text: String) { + sendAsrEvent(mapOf( + "type" to "result", + "text" to text + )) + } + + override fun onRecognizing(text: String) { + sendAsrEvent(mapOf( + "type" to "recognizing", + "text" to text + )) + } + + override fun onSessionStarted() { + sendAsrEvent(mapOf("type" to "sessionStarted")) + } + + override fun onSessionStopped() { + sendAsrEvent(mapOf("type" to "sessionStopped")) + } + + override fun onVolumeChanged(volume: Int) { + sendAsrEvent(mapOf( + "type" to "volumeChanged", + "volume" to volume + )) + } + + override fun onError(error: String) { + sendAsrEvent(mapOf( + "type" to "error", + "message" to error + )) + } + }) + + result.success(success) + } + "stopContinuousRecognition" -> { + val success = volcanoAsrHelper.stopContinuousRecognition() + result.success(success) + } + "stopRecognize" -> { + val success = volcanoAsrHelper.stopRecognize() + result.success(success) + } + "isContinuousRecognitionActive" -> { + result.success(volcanoAsrHelper.isContinuousRecognitionActive()) + } + "release" -> { + volcanoAsrHelper.release() + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + } + + // TTS方法处理器 + inner class TtsMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "initialize" -> { + val appId = call.argument("appId") ?: "" + val token = call.argument("token") ?: "" + val resourceId = call.argument("resourceId") ?: "" + + if (appId.isEmpty() || token.isEmpty() || resourceId.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId、token和resourceId不能为空", null) + return + } + + val success = volcanoTtsHelper.initialize(appId, token, resourceId) + result.success(success) + } + "setVoice" -> { + val voice = call.argument("voice") ?: "" + + if (voice.isEmpty()) { + result.error("INVALID_ARGUMENTS", "voice不能为空", null) + return + } + + result.success(volcanoTtsHelper.setVoice(voice)) + } + "setContinuousMode" -> { + val isContinuous = call.argument("isContinuous") ?: false + result.success(volcanoTtsHelper.setContinuousMode(isContinuous)) + } + "speak" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { + override fun onStart(reqId: String) { + sendTtsEvent(mapOf( + "type" to "start", + "reqId" to reqId + )) + } + + override fun onProgress(reqId: String, progress: Double) { + sendTtsEvent(mapOf( + "type" to "progress", + "reqId" to reqId, + "progress" to progress + )) + } + + override fun onComplete(reqId: String) { + sendTtsEvent(mapOf( + "type" to "complete", + "reqId" to reqId + )) + + mainHandler.post { + result.success(true) + } + } + + override fun onError(reqId: String, errorCode: Int, errorMsg: String) { + sendTtsEvent(mapOf( + "type" to "error", + "reqId" to reqId, + "errorCode" to errorCode, + "errorMsg" to errorMsg + )) + + mainHandler.post { + result.error("TTS_ERROR", errorMsg, null) + } + } + }) + } + "synthesisNext" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + val success = volcanoTtsHelper.synthesisNext(text) + result.success(success) + } + "pause" -> { + result.success(volcanoTtsHelper.pause()) + } + "resume" -> { + result.success(volcanoTtsHelper.resume()) + } + "stop" -> { + result.success(volcanoTtsHelper.stop()) + } + "release" -> { + volcanoTtsHelper.release() + result.success(true) + } + // 以下方法用于与Azure Speech版本兼容 + "setSpeechSynthesisVoice" -> { + val voiceName = call.argument("voiceName") ?: "" + + if (voiceName.isEmpty()) { + result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) + return + } + + result.success(volcanoTtsHelper.setVoice(voiceName)) + } + "speakText" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { + override fun onStart(reqId: String) { + sendTtsEvent(mapOf( + "type" to "start", + "reqId" to reqId + )) + } + + override fun onProgress(reqId: String, progress: Double) { + sendTtsEvent(mapOf( + "type" to "progress", + "reqId" to reqId, + "progress" to progress + )) + } + + override fun onComplete(reqId: String) { + sendTtsEvent(mapOf( + "type" to "complete", + "reqId" to reqId + )) + + mainHandler.post { + result.success(true) + } + } + + override fun onError(reqId: String, errorCode: Int, errorMsg: String) { + sendTtsEvent(mapOf( + "type" to "error", + "reqId" to reqId, + "errorCode" to errorCode, + "errorMsg" to errorMsg + )) + + mainHandler.post { + result.error("TTS_ERROR", errorMsg, null) + } + } + }) + } + "stopSpeaking" -> { + result.success(volcanoTtsHelper.stop()) + } + "pauseSpeaking" -> { + result.success(volcanoTtsHelper.pause()) + } + "resumeSpeaking" -> { + result.success(volcanoTtsHelper.resume()) + } + else -> { + result.notImplemented() + } + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + asrChannel.setMethodCallHandler(null) + ttsChannel.setMethodCallHandler(null) + asrEventChannel.setStreamHandler(null) + ttsEventChannel.setStreamHandler(null) + + volcanoTtsHelper.release() + volcanoAsrHelper.release() + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt new file mode 100644 index 000000000..4642272d4 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt @@ -0,0 +1,438 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.bytedance.speech.speechengine.SpeechEngine +import com.bytedance.speech.speechengine.SpeechEngineDefines +import com.bytedance.speech.speechengine.SpeechEngineGenerator +import com.yunqiinnovation.volcano_speech.utils.FileLogger +import org.json.JSONObject + +/** + * 火山语音合成帮助类 (大模型版本) + */ +class VolcanoTtsHelper(private val context: Context) { + private val TAG = "VolcanoTtsHelper" + private val mainHandler = Handler(Looper.getMainLooper()) + + // 语音引擎相关 + private var engine: SpeechEngine? = null + private var engineHandler: Long = -1 + private var isInitialized = false + + // 当前回调 + private var currentTtsCallback: TTSCallback? = null + + // 合成状态 + private var isPlaying = false + private var currentReqId = "" + + // 配置参数 + private var voice = "zh_male_M392_conversation_wvae_bigtts" + private var ttsText = "" + private var isContinuous = false + + /** + * TTS合成回调接口 + */ + interface TTSCallback { + fun onStart(reqId: String) + fun onProgress(reqId: String, progress: Double) + fun onComplete(reqId: String) + fun onError(reqId: String, errorCode: Int, errorMsg: String) + } + + /** + * 初始化语音合成引擎 + */ + fun initialize(appId: String, token: String, resourceId: String): Boolean { + if (isInitialized) { + FileLogger.i(TAG, "引擎已经初始化") + return true + } + FileLogger.d(TAG, "初始化语音合成引擎: $appId, $token, $resourceId") + try { + // 准备环境 + try { + SpeechEngineGenerator.PrepareEnvironment(context, null) + } catch (e: Exception) { + FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") + // 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类 + } + + // 创建引擎 + engine = SpeechEngineGenerator.getInstance() + engineHandler = engine?.createEngine() ?: -1 + + if (engineHandler == -1L) { + FileLogger.e(TAG, "创建引擎失败") + return false + } + + // 设置上下文 + engine?.setContext(context) + + // 设置引擎类型为TTS + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE) + + // 设置日志级别 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_DEBUG) + // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") + + // 设置授权信息 - 大模型版本不需要Bearer前缀 + FileLogger.d(TAG, "设置授权信息: $appId, $token, $resourceId") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, "Bearer;$token") + + // 设置资源ID - 大模型必需 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + + // 设置协议类型为Seed - 大模型必需 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) + + // 设置合成策略为在线合成 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE) + + // 设置在线请求资源配置 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, "wss://openspeech.bytedance.com") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v1/tts/ws_binary") + //【必需配置】语音合成服务所用集群 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, "volcano_tts") + // 设置发音人 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) + + // 设置播放进度回调 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1) + + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA); + + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL) + + // 初始化引擎 + val result = engine?.initEngine(engineHandler) + isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR + + if (isInitialized) { + FileLogger.i(TAG, "引擎初始化成功") + + // 设置回调监听 + engine?.setListener(object : SpeechEngine.SpeechListener { + override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { + val stdData = String(data) + handleEngineEvent(type, stdData) + } + }) + + return true + } else { + // 错误码说明: + // -1:通用错误 + // -2:无效参数 + // -3:无效状态 + // -4:无效引擎 + // -5:操作失败 + // -6:缺少配置 + // -7:参数无效 + // -8:超时 + // -9:资源不足 + FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化异常: ${e.message}", e) + return false + } + } + + /** + * 设置发音人 + */ + fun setVoice(voice: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.voice = voice + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置发音人失败: ${e.message}", e) + return false + } + } + + /** + * 设置合成场景(单次或连续) + */ + fun setContinuousMode(isContinuous: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.isContinuous = isContinuous + val scenarioType = if (isContinuous) { + SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL + } else { + SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL + } + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, scenarioType) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置合成场景失败: ${e.message}", e) + return false + } + } + + /** + * 开始合成并播放 + */ + fun speak(text: String, callback: TTSCallback? = null): Boolean { + FileLogger.d(TAG, "开始合成并播放: $text") + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (text.isEmpty()) { + FileLogger.e(TAG, "合成文本为空") + return false + } + + try { + this.ttsText = text + this.currentTtsCallback = callback + + // 设置要合成的文本 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始合成 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动合成失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "合成异常: ${e.message}", e) + return false + } + } + + /** + * 仅用于连续合成场景:在引擎启动后添加新的文本进行合成 + */ + fun synthesisNext(text: String): Boolean { + if (!isInitialized || !isContinuous) { + FileLogger.e(TAG, "引擎未初始化或非连续合成模式") + return false + } + + if (text.isEmpty()) { + FileLogger.e(TAG, "合成文本为空") + return false + } + + try { + // 设置要合成的文本 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + + // 发送合成指令 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "添加合成文本失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "添加合成文本异常: ${e.message}", e) + return false + } + } + + /** + * 暂停播放 + */ + fun pause(): Boolean { + if (!isInitialized || !isPlaying) { + FileLogger.e(TAG, "引擎未初始化或未在播放") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_PAUSE_PLAYER, "") + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "暂停播放异常: ${e.message}", e) + return false + } + } + + /** + * 恢复播放 + */ + fun resume(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_RESUME_PLAYER, "") + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "恢复播放异常: ${e.message}", e) + return false + } + } + + /** + * 停止播放 + */ + fun stop(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + isPlaying = false + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "停止播放异常: ${e.message}", e) + return false + } + } + + /** + * 释放资源 + */ + fun release() { + if (!isInitialized) { + return + } + + try { + // 停止引擎 + stop() + + // 销毁引擎 + engine?.destroyEngine(engineHandler) + engineHandler = -1 + engine = null + isInitialized = false + + FileLogger.i(TAG, "引擎已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) + } + } + + /** + * 处理引擎事件 + */ + private fun handleEngineEvent(type: Int, data: String) { + when (type) { + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { + // 引擎启动成功,获取请求ID + currentReqId = data + isPlaying = true + + mainHandler.post { + currentTtsCallback?.onStart(currentReqId) + } + + if (isContinuous) { + // 在连续合成模式下,需要单独发送合成指令 + synthesisNext(ttsText) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { + // 引擎停止 + isPlaying = false + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { + // 开始播放 + FileLogger.d(TAG, "开始播放: $data") + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { + // 播放结束 + isPlaying = false + + mainHandler.post { + currentTtsCallback?.onComplete(currentReqId) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { + // 播放进度 + try { + val json = JSONObject(data) + val progress = json.optDouble("progress", 0.0) + val reqId = json.optString("reqid", "") + + mainHandler.post { + currentTtsCallback?.onProgress(reqId, progress) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析进度信息异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { + // 错误信息 + try { + val json = JSONObject(data) + val reqId = json.optString("reqid", "") + val errCode = json.optInt("err_code", -1) + val errMsg = json.optString("err_msg", "未知错误") + + FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") + + isPlaying = false + + mainHandler.post { + currentTtsCallback?.onError(reqId, errCode, errMsg) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) + } + } + } + } + + /** + * 获取错误描述 + */ + private fun getErrorDescription(errorCode: Int?): String { + return when (errorCode) { + -1 -> "通用错误" + -2 -> "无效参数,检查appId、token、resourceId等参数是否正确" + -3 -> "无效状态,引擎状态异常" + -4 -> "无效引擎句柄" + -5 -> "操作失败,可能网络连接问题" + -6 -> "缺少配置参数" + -7 -> "参数设置无效,请检查appId、token等参数是否有效" + -8 -> "操作超时" + -9 -> "资源不足" + else -> "未知错误: $errorCode" + } + } +} diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt new file mode 100644 index 000000000..8fdfb1227 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt @@ -0,0 +1,55 @@ +package com.yunqiinnovation.volcano_speech.utils + +import android.util.Log + +/** + * 文件日志记录工具 + */ +object FileLogger { + private const val TAG = "VolcanoSpeech" + private var isDebugEnabled = true + + /** + * 设置是否启用调试日志 + */ + fun setDebugEnabled(enabled: Boolean) { + isDebugEnabled = enabled + } + + /** + * 记录调试日志 + */ + fun d(tag: String, message: String) { + if (isDebugEnabled) { + Log.d("$TAG-$tag", message) + } + } + + /** + * 记录信息日志 + */ + fun i(tag: String, message: String) { + Log.i("$TAG-$tag", message) + } + + /** + * 记录警告日志 + */ + fun w(tag: String, message: String) { + Log.w("$TAG-$tag", message) + } + + /** + * 记录错误日志 + */ + fun e(tag: String, message: String) { + Log.e("$TAG-$tag", message) + } + + /** + * 记录错误日志,带异常 + */ + fun e(tag: String, message: String, throwable: Throwable) { + Log.e("$TAG-$tag", message, throwable) + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/ios/Classes/.gitkeep b/local_plugins/volcano_speech/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/volcano_speech/ios/volcano_speech.podspec b/local_plugins/volcano_speech/ios/volcano_speech.podspec new file mode 100644 index 000000000..9a70baed1 --- /dev/null +++ b/local_plugins/volcano_speech/ios/volcano_speech.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint volcano_speech.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'volcano_speech' + s.version = '0.0.1' + s.summary = 'Volcano Speech plugin' + s.description = <<-DESC +A Flutter plugin for Volcano Speech services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/volcano_speech/lib/volcano_speech.dart b/local_plugins/volcano_speech/lib/volcano_speech.dart new file mode 100644 index 000000000..99da7d4b7 --- /dev/null +++ b/local_plugins/volcano_speech/lib/volcano_speech.dart @@ -0,0 +1,603 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; + +/// ASR 事件类型 +enum AsrEventType { + /// 会话开始 + sessionStarted, + + /// 会话结束 + sessionStopped, + + /// 正在识别(中间结果) + recognizing, + + /// 识别结果(最终结果) + result, + + /// 音量变化 + volumeChanged, + + /// 错误 + error +} + +/// TTS 工作模式 +enum TtsWorkMode { + /// 在线合成 + online, + + /// 离线合成 + offline, + + /// 同时在线离线 + both, + + /// 先在线再离线(网络不好时自动切换) + alternate, + + /// 文件模式 + file +} + +/// TTS 文本类型 +enum TtsTextType { + /// 纯文本 + plain, + + /// SSML格式 + ssml +} + +/// 协议类型 +enum ProtocolType { + /// 默认协议 + defaultProtocol, + + /// Seed协议(用于大模型) + seed +} + +/// TTS 播放进度事件 +class TtsProgressEvent { + /// 播放进度 0.0-1.0 + final double progress; + + /// 请求ID + final String reqId; + + const TtsProgressEvent({ + required this.progress, + required this.reqId, + }); + + @override + String toString() { + return 'TtsProgressEvent{progress: $progress, reqId: $reqId}'; + } +} + +/// ASR 事件 +class AsrEvent { + /// 事件类型 + final AsrEventType type; + + /// 识别文本(仅在recognizing和result类型时有效) + final String? text; + + /// 识别语言(仅在recognizing和result类型时有效) + final String? language; + + /// 音量值(仅在volumeChanged类型时有效) + final int? volume; + + /// 错误信息(仅在error类型时有效) + final String? errorMessage; + + const AsrEvent({ + required this.type, + this.text, + this.language, + this.volume, + this.errorMessage, + }); + + factory AsrEvent.fromMap(Map map) { + final typeStr = map['type'] as String; + + AsrEventType type; + switch (typeStr) { + case 'sessionStarted': + type = AsrEventType.sessionStarted; + break; + case 'sessionStopped': + type = AsrEventType.sessionStopped; + break; + case 'recognizing': + type = AsrEventType.recognizing; + break; + case 'result': + type = AsrEventType.result; + break; + case 'volumeChanged': + type = AsrEventType.volumeChanged; + break; + case 'error': + type = AsrEventType.error; + break; + default: + throw ArgumentError('未知的事件类型: $typeStr'); + } + + return AsrEvent( + type: type, + text: map['text'] as String?, + language: map['language'] as String?, + volume: map['volume'] as int?, + errorMessage: map['message'] as String?, + ); + } + + @override + String toString() { + return 'AsrEvent{type: $type, text: $text, language: $language, volume: $volume, errorMessage: $errorMessage}'; + } +} + +/// 火山引擎语音服务插件 +class VolcanoSpeech { + static final VolcanoSpeechAsr asr = VolcanoSpeechAsr._(); + static final VolcanoSpeechTts tts = VolcanoSpeechTts._(); + + /// 释放资源 + static Future dispose() async { + await asr.dispose(); + await tts.dispose(); + } +} + +/// 火山引擎语音识别服务 +class VolcanoSpeechAsr { + static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); + static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events'); + + /// ASR事件流控制器 + static final StreamController _eventStreamController = StreamController.broadcast(); + + /// ASR事件流 + Stream get events => _eventStreamController.stream; + + /// 是否已初始化事件监听 + bool _eventListenerInitialized = false; + + VolcanoSpeechAsr._() { + _initEventListener(); + } + + /// 初始化ASR事件监听 + void _initEventListener() { + if (_eventListenerInitialized) return; + + _eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Map) { + _eventStreamController.add(AsrEvent.fromMap(event)); + } + }); + + _eventListenerInitialized = true; + } + + /// 获取平台版本信息 + Future getPlatformVersion() async { + return await _channel.invokeMethod('getPlatformVersion'); + } + + /// 初始化语音识别引擎 + /// + /// [appId] 火山引擎AppID + /// [apiKey] 火山引擎ApiKey + /// [supportedLanguages] 支持的语言列表 + /// [useBigModel] 是否使用大模型识别 + /// [resourceId] 大模型资源ID(仅当useBigModel为true时有效) + Future initialize({ + required String appId, + required String apiKey, + List supportedLanguages = const ['zh-CN'], + bool useBigModel = false, + String resourceId = '', + }) async { + return await _channel.invokeMethod('initialize', { + 'appId': appId, + 'apiKey': apiKey, + 'supportedLanguages': supportedLanguages, + 'useBigModel': useBigModel, + 'resourceId': resourceId, + }) ?? false; + } + + /// 设置识别语言 + /// + /// [language] 语言代码,例如 zh-CN、en-US + Future setLanguage(String language) async { + return await _channel.invokeMethod('setLanguage', { + 'language': language, + }) ?? false; + } + + /// 设置热词 + /// + /// [hotWords] 热词JSON字符串,例如 {"hotwords":[{"word":"快速入门","scale":2.0}]} + Future setHotWords(String hotWords) async { + return await _channel.invokeMethod('setHotWords', { + 'hotWords': hotWords, + }) ?? false; + } + + /// 设置ASR请求参数 + /// + /// [params] 请求参数JSON字符串 + Future setRequestParams(String params) async { + return await _channel.invokeMethod('setRequestParams', { + 'params': params, + }) ?? false; + } + + /// 启用语音停顿、分句、分词信息输出 + /// + /// [enable] 是否启用 + Future setShowUtterances(bool enable) async { + return await _channel.invokeMethod('setShowUtterances', { + 'enable': enable, + }) ?? false; + } + + /// 一次性识别(直到说话结束) + /// + /// 返回识别结果文本和语言 + Future> recognizeOnce() async { + final result = await _channel.invokeMethod('recognizeOnce'); + return { + 'text': result['text'] ?? '', + 'language': result['language'] ?? 'zh-CN', + }; + } + + /// 开始连续识别 + /// + /// 通过[events]流监听识别结果 + Future startContinuousRecognition() async { + return await _channel.invokeMethod('startContinuousRecognition') ?? false; + } + + /// 停止连续识别 + Future stopContinuousRecognition() async { + return await _channel.invokeMethod('stopContinuousRecognition') ?? false; + } + + /// 检查是否正在连续识别 + Future isContinuousRecognitionActive() async { + return await _channel.invokeMethod('isContinuousRecognitionActive') ?? false; + } + + /// 开始长按识别(按下开始,抬起结束) + /// + /// 通过[events]流监听识别结果 + Future startListening() async { + return await _channel.invokeMethod('startListening') ?? false; + } + + /// 停止长按识别 + Future stopListening() async { + return await _channel.invokeMethod('stopListening') ?? false; + } + + /// 释放ASR资源 + Future dispose() async { + return await _channel.invokeMethod('dispose') ?? false; + } +} + +/// 火山引擎语音合成服务 +class VolcanoSpeechTts { + static const MethodChannel _channel = MethodChannel('volcano_speech/tts'); + static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events'); + + /// TTS进度事件流控制器 + static final StreamController _progressStreamController = StreamController.broadcast(); + + /// TTS进度事件流 + Stream get progressEvents => _progressStreamController.stream; + + /// 是否已初始化事件监听 + bool _eventListenerInitialized = false; + + VolcanoSpeechTts._() { + _initEventListener(); + } + + /// 初始化TTS事件监听 + void _initEventListener() { + if (_eventListenerInitialized) return; + + _eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Map) { + final progress = event['progress'] as double?; + final reqId = event['reqId'] as String?; + + if (progress != null && reqId != null) { + _progressStreamController.add(TtsProgressEvent( + progress: progress, + reqId: reqId, + )); + } + } + }); + + _eventListenerInitialized = true; + } + + /// 获取平台版本信息 + Future getPlatformVersion() async { + return await _channel.invokeMethod('getPlatformVersion'); + } + + /// 启用大模型TTS + /// + /// [enable] 是否启用大模型TTS + /// [resourceId] 资源ID + Future enableBigModelTts({ + required bool enable, + String resourceId = '', + }) async { + return await _channel.invokeMethod('enableBigModelTts', { + 'enable': enable, + 'resourceId': resourceId, + }) ?? false; + } + + /// 初始化语音合成引擎 + /// + /// [appId] 火山引擎AppID + /// [apiKey] 火山引擎ApiKey + Future initialize({ + required String appId, + required String apiKey, + }) async { + return await _channel.invokeMethod('initialize', { + 'appId': appId, + 'apiKey': apiKey, + }) ?? false; + } + + /// 设置音色 + /// + /// [voiceName] 音色名称 + /// [voiceType] 音色类型,默认 qingxin + Future setVoice({ + required String voiceName, + String voiceType = 'qingxin', + }) async { + return await _channel.invokeMethod('setVoice', { + 'voiceName': voiceName, + 'voiceType': voiceType, + }) ?? false; + } + + /// 设置离线音色 + /// + /// [voiceName] 音色名称 + /// [voiceType] 音色类型,默认 qingxin + Future setOfflineVoice({ + required String voiceName, + String voiceType = 'qingxin', + }) async { + return await _channel.invokeMethod('setOfflineVoice', { + 'voiceName': voiceName, + 'voiceType': voiceType, + }) ?? false; + } + + /// 设置大模型声音ID + /// + /// [voiceId] 声音ID + Future setBigModelVoiceId(String voiceId) async { + return await _channel.invokeMethod('setBigModelVoiceId', { + 'voiceId': voiceId, + }) ?? false; + } + + /// 设置大模型请求参数 + /// + /// [params] 请求参数,JSON字符串 + Future setBigModelRequestParams(String params) async { + return await _channel.invokeMethod('setBigModelRequestParams', { + 'params': params, + }) ?? false; + } + + /// 设置工作模式 + /// + /// [mode] 工作模式 + Future setWorkMode(TtsWorkMode mode) async { + String modeStr; + switch (mode) { + case TtsWorkMode.online: + modeStr = 'online'; + break; + case TtsWorkMode.offline: + modeStr = 'offline'; + break; + case TtsWorkMode.both: + modeStr = 'both'; + break; + case TtsWorkMode.alternate: + modeStr = 'alternate'; + break; + case TtsWorkMode.file: + modeStr = 'file'; + break; + } + + return await _channel.invokeMethod('setWorkMode', { + 'mode': modeStr, + }) ?? false; + } + + /// 设置文本类型 + /// + /// [type] 文本类型,plain或ssml + Future setTextType(TtsTextType type) async { + String typeStr; + switch (type) { + case TtsTextType.plain: + typeStr = 'plain'; + break; + case TtsTextType.ssml: + typeStr = 'ssml'; + break; + } + + return await _channel.invokeMethod('setTextType', { + 'type': typeStr, + }) ?? false; + } + + /// 设置是否启用缓存 + /// + /// [enable] 是否启用 + Future setEnableCache(bool enable) async { + return await _channel.invokeMethod('setEnableCache', { + 'enable': enable, + }) ?? false; + } + + /// 设置情感 + /// + /// [emotion] 情感,例如 neutral、happy、angry、sad等 + Future setEmotion(String emotion) async { + return await _channel.invokeMethod('setEmotion', { + 'emotion': emotion, + }) ?? false; + } + + /// 设置是否启用情感预测 + /// + /// [enable] 是否启用 + Future setEnableEmotionPredict(bool enable) async { + return await _channel.invokeMethod('setEnableEmotionPredict', { + 'enable': enable, + }) ?? false; + } + + /// 设置是否启用声音克隆 + /// + /// [enable] 是否启用 + /// [backendCluster] 后端集群 + Future setEnableVoiceClone({ + required bool enable, + String backendCluster = '', + }) async { + return await _channel.invokeMethod('setEnableVoiceClone', { + 'enable': enable, + 'backendCluster': backendCluster, + }) ?? false; + } + + /// 设置是否启用回声消除 + /// + /// [enable] 是否启用 + Future setEnableAEC(bool enable) async { + return await _channel.invokeMethod('setEnableAEC', { + 'enable': enable, + }) ?? false; + } + + /// 下载离线资源 + /// + /// [voiceTypes] 音色类型列表 + /// [languages] 语言列表 + Future downloadOfflineResource({ + List voiceTypes = const ['qingxin'], + List languages = const ['zh-CN'], + }) async { + return await _channel.invokeMethod('downloadOfflineResource', { + 'voiceTypes': voiceTypes, + 'languages': languages, + }) ?? false; + } + + /// 设置语音参数 + /// + /// [rate] 语速 -500~500 + /// [volume] 音量 0~100 + /// [pitch] 音调 -500~500 + /// [silenceDuration] 静音时长,毫秒 + Future setSpeechParams({ + int rate = 0, + int volume = 100, + int pitch = 0, + int silenceDuration = 0, + }) async { + return await _channel.invokeMethod('setSpeechParams', { + 'rate': rate, + 'volume': volume, + 'pitch': pitch, + 'silenceDuration': silenceDuration, + }) ?? false; + } + + /// 设置音频输出类型 + /// + /// [outputType] 输出类型,speaker(扬声器),earpiece(听筒),auto(自动) + Future setAudioOutputType(String outputType) async { + return await _channel.invokeMethod('setAudioOutputType', { + 'outputType': outputType, + }) ?? false; + } + + /// 合成并播放文本 + /// + /// [text] 待合成的文本 + Future speakText(String text) async { + return await _channel.invokeMethod('speakText', { + 'text': text, + }) ?? false; + } + + /// 使用大模型合成并播放文本 + /// + /// [text] 待合成的文本 + /// [voiceId] 声音ID + /// [params] 额外参数,JSON字符串 + Future speakWithBigModel({ + required String text, + required String voiceId, + String params = '', + }) async { + return await _channel.invokeMethod('speakWithBigModel', { + 'text': text, + 'voiceId': voiceId, + 'params': params, + }) ?? false; + } + + /// 暂停播放 + Future pausePlayback() async { + return await _channel.invokeMethod('pausePlayback') ?? false; + } + + /// 恢复播放 + Future resumePlayback() async { + return await _channel.invokeMethod('resumePlayback') ?? false; + } + + /// 停止播放 + Future stopSpeaking() async { + return await _channel.invokeMethod('stopSpeaking') ?? false; + } + + /// 释放TTS资源 + Future dispose() async { + return await _channel.invokeMethod('dispose') ?? false; + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/pubspec.yaml b/local_plugins/volcano_speech/pubspec.yaml new file mode 100644 index 000000000..cd90d8b14 --- /dev/null +++ b/local_plugins/volcano_speech/pubspec.yaml @@ -0,0 +1,28 @@ +name: volcano_speech +description: 火山引擎语音合成服务插件 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + plugin: + platforms: + android: + package: com.yunqiinnovation.volcano_speech + pluginClass: VolcanoSpeechPlugin + ios: + pluginClass: VolcanoSpeechPlugin \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 533f3dd20..47da8b194 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,20 @@ dependencies: dio: ^5.8.0+1 sqflite: ^2.4.2 path: ^1.9.1 + azure_speech: + path: local_plugins/azure_speech + open_ai_service: + path: local_plugins/open_ai_service + volcano_speech: + path: local_plugins/volcano_speech + jieli: + path: local_plugins/jieli + chat_storage: + path: local_plugins/chat_storage + spotify_sdk: + path: local_plugins/spotify_sdk + amap_flutter: + path: amap_flutter dev_dependencies: flutter_test: @@ -81,3 +95,4 @@ flutter: - assets/images/explore2.jpg - assets/lottie/ - .env + - .mcp.json diff --git a/test.json b/test.json new file mode 100644 index 000000000..208c91d82 --- /dev/null +++ b/test.json @@ -0,0 +1,173 @@ +curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ +-H "Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6" \ +-H 'Content-Type: application/json' \ +-d '{ + "model": "bot-20250405211523-l7c9r", + "stream": true, + "stream_options": {"include_usage": true}, + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "role": "user", + "content": "Hello!" + } + ] +}' +"map-qq": { + "url": "https://mcp.map.qq.com/sse?key=E5ABZ-RIACZ-DX2XC-7DEU2-AXR2Q-V7BWQ" +}, + +"amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=66b3f6199e3561f874a5a9cd9e9ed7f2" +}, + // "map-qq": { + // "url": "https://mcp.map.qq.com?key=E5ABZ-RIACZ-DX2XC-7DEU2-AXR2Q-V7BWQ" + // }, + "web-search": { + "url": "http://mcp.ideapsound.com:8000" + }, + +curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' \ +-d '{ + "model": "bot-20250405211523-l7c9r", + "stream": true, + "messages": [ + { + "role": "assistant", + "content": "你好呀!今天想和我聊些什么呢😄 是生活趣事、学习心得,还是其他好玩的话题?" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "assistant", + "content": "你好呀!今天过得怎么样?有什么需要我帮忙的吗?或者只是想聊聊天?😊" + }, + { + "role": "assistant", + "content": "明白啦!有什么需要我帮忙的吗?无论是查资料、聊天解闷,还是帮你处理手机上的任务,我随时待命哦~ 😊" + }, + { + "role": "assistant", + "content": "好的!随时准备陪你测试,有什么需要尽管说哦~ 😊 你希望测试什么功能呢?" + }, + { + "role": "assistant", + "content": "哎呀,听起来你有点小困扰呢!可能是因为网络波动、系统更新或者我偶尔“打瞌睡”了(开玩笑啦~)。不过别担心,我会努力保持最佳状态陪伴你!如果有什么具体问题,随时告诉我,我一定帮你解决得妥妥的!😊\n\n(或者你想测试什么功能?我随时待命哦!)" + }, + { + "role": "assistant", + "content": "嘿嘿,我是你的贴心语音助理小语呀!随时为你提供帮助、陪你聊天、解答问题,还能给你加油打气哦~有什么需要尽管告诉我吧! 😊" + }, + { + "role": "assistant", + "content": "我是小语呀,那个随时准备和你畅聊、给你排忧解难、陪你度过各种时光的手机全能语音助理😜 怎么啦,是不是刚才没记住呀🧐" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "user", + "content": "你好呀,你是谁呀?" + } + ], + "temperature": 0.7, + "max_tokens": 2000, + "stream": true, + "tools": [ + { + "type": "function", + "function": { + "name": "exit_interaction", + "description": "退出当前语音交互", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "enter_translation_mode", + "description": "用户请求进入实时翻译模式时,启动实时翻译功能", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "send_text_message", + "description": "发送短信", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" + } + }, + "required": [ + "contact", + "message" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "make_phone_call", + "description": "拨打电话", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": [ + "contact" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "open_app", + "description": "当用户请求打开特定应用时,启动相应应用", + "parameters": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": [ + "app_name" + ] + } + } + } + ] +}' +