Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
@@ -0,0 +1 @@
{"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.dbankcloud.cn","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-dre.hispace.hicloud.com","DE_back":"connect-dre.hispace.hicloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864901451","client_id":"2029248924690199744","client_secret":"2540EB569E83BF5271BC689A63FB0540832E4849A9608BB814D463EFA6754FA4","project_id":"101653523864901451","app_id":"118825899","api_key":"DgEDALOHkJa4k9Q93H74q1jSF7G3GWcDUQIqrJBqwJFC3Bcyrg+TEK1K5FdhJHh++SBQQ/QxRTdQXOO+XOq/8r5qdZ6rGi7ISmKJlw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"},"configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118825899"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"}}]}
+68
View File
@@ -0,0 +1,68 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.fysxkj.fengying_oa"
compileSdk = flutter.compileSdkVersion
// LiveKit/WebRTC and the current Android plugins require NDK 27+.
// 28.2 is installed in the build environment and is backwards compatible.
ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.fengying.oa.mobile"
minSdkVersion = 23
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
release {
keyAlias = keystoreProperties["keyAlias"]
keyPassword = keystoreProperties["keyPassword"]
storeFile = keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
storePassword = keystoreProperties["storePassword"]
}
}
buildTypes {
release {
signingConfig = signingConfigs.release
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}
flutter {
source = "../.."
}
dependencies {
implementation "vivo:vpush:4.1.5.0"
implementation "com.heytap.msp:push:3.0.0"
implementation files("libs/push-internal-5.0.5.aar")
implementation "com.google.code.gson:gson:2.10.1"
implementation "com.huawei.agconnect:agconnect-core:1.9.0.300"
implementation "com.huawei.hms:push:6.13.0.300"
implementation "com.alphacephei:vosk-android:0.3.75@aar"
implementation "net.java.dev.jna:jna:5.13.0@aar"
}
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
-dontwarn com.vivo.push.**
-keep class com.vivo.push.** { *; }
-keep class com.vivo.vms.** { *; }
-dontwarn com.heytap.**
-keep class com.heytap.** { *; }
-dontwarn com.meizu.**
-keep class com.meizu.** { *; }
-keep class com.fysxkj.fengying_oa.VivoPushReceiver { *; }
-keep class com.fysxkj.fengying_oa.OppoPushCallback { *; }
-keep class com.fysxkj.fengying_oa.OppoDataMessageService { *; }
-keep class com.fysxkj.fengying_oa.OppoCompatPushService { *; }
-keep class com.fysxkj.fengying_oa.MeizuPushReceiver { *; }
-keep class com.fysxkj.fengying_oa.HuaweiPushService { *; }
-dontwarn com.huawei.**
-keep class com.huawei.** { *; }
-keep class com.huawei.hianalytics.** { *; }
-keep class com.huawei.updatesdk.** { *; }
-keep class org.webrtc.** { *; }
-dontwarn org.webrtc.**
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,176 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE"/>
<uses-permission android:name="com.coloros.mcs.permission.RECIEVE_MCS_MESSAGE"/>
<uses-permission android:name="com.heytap.mcs.permission.RECIEVE_MCS_MESSAGE"/>
<uses-permission android:name="com.meizu.flyme.push.permission.RECEIVE"/>
<uses-permission android:name="com.meizu.flyme.permission.PUSH"/>
<uses-permission android:name="com.meizu.c2dm.permission.RECEIVE"/>
<permission android:name="${applicationId}.push.permission.MESSAGE" android:protectionLevel="signature"/>
<uses-permission android:name="${applicationId}.push.permission.MESSAGE"/>
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
<application
android:label="风影智慧办公"
android:name=".FengyingApp"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:resizeableActivity="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fengyingoa" android:host="chat"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fengyingoa" android:host="qr"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="api_key"
android:value="b521c805229a00e85a53d4b535a0cafe"/>
<meta-data
android:name="app_id"
android:value="106114371"/>
<receiver
android:name=".VivoPushReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.vivo.pushclient.action.RECEIVE"/>
</intent-filter>
</receiver>
<service
android:name="com.vivo.push.sdk.service.CommandClientService"
android:permission="com.push.permission.UPSTAGESERVICE"
android:exported="true"/>
<service
android:name=".OppoDataMessageService"
android:permission="com.heytap.mcs.permission.SEND_PUSH_MESSAGE"
android:exported="true">
<intent-filter>
<action android:name="com.heytap.mcs.action.RECEIVE_MCS_MESSAGE"/>
<action android:name="com.heytap.msp.push.RECEIVE_MCS_MESSAGE"/>
</intent-filter>
</service>
<service
android:name=".OppoCompatPushService"
android:permission="com.coloros.mcs.permission.SEND_MCS_MESSAGE"
android:exported="true">
<intent-filter>
<action android:name="com.coloros.mcs.action.RECEIVE_MCS_MESSAGE"/>
</intent-filter>
</service>
<receiver
android:name=".MeizuPushReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.meizu.flyme.push.intent.MESSAGE"/>
<action android:name="com.meizu.flyme.push.intent.REGISTER.FEEDBACK"/>
<action android:name="com.meizu.flyme.push.intent.UNREGISTER.FEEDBACK"/>
<action android:name="com.meizu.c2dm.intent.REGISTRATION"/>
<action android:name="com.meizu.c2dm.intent.RECEIVE"/>
<category android:name="${applicationId}"/>
</intent-filter>
</receiver>
<service
android:name=".HuaweiPushService"
android:exported="false">
<intent-filter>
<action android:name="com.huawei.push.action.MESSAGING_EVENT"/>
</intent-filter>
</service>
<meta-data
android:name="com.huawei.hms.client.appid"
android:value="appid=118825899"/>
<meta-data
android:name="push_kit_auto_init_enabled"
android:value="true"/>
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:mimeType="*/*"/>
</intent>
<intent>
<action android:name="android.intent.action.SEND"/>
<data android:mimeType="*/*"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="geo"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="amapuri"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="qqmap"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:mimeType="application/vnd.android.package-archive"/>
</intent>
<package android:name="com.coloros.safecenter"/>
<package android:name="com.oplus.safecenter"/>
<package android:name="com.oppo.safe"/>
<package android:name="com.vivo.permissionmanager"/>
<package android:name="com.iqoo.secure"/>
<package android:name="com.huawei.hwid"/>
<package android:name="com.huawei.android.pushagent"/>
<package android:name="com.meizu.safe"/>
<package android:name="com.meizu.cloud"/>
</queries>
</manifest>
@@ -0,0 +1 @@
{"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.dbankcloud.cn","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-dre.hispace.hicloud.com","DE_back":"connect-dre.hispace.hicloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864901451","client_id":"2029248924690199744","client_secret":"2540EB569E83BF5271BC689A63FB0540832E4849A9608BB814D463EFA6754FA4","project_id":"101653523864901451","app_id":"118825899","api_key":"DgEDALOHkJa4k9Q93H74q1jSF7G3GWcDUQIqrJBqwJFC3Bcyrg+TEK1K5FdhJHh++SBQQ/QxRTdQXOO+XOq/8r5qdZ6rGi7ISmKJlw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"},"configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118825899"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"}}]}
@@ -0,0 +1,43 @@
package com.fysxkj.fengying_oa
import kotlin.math.PI
import kotlin.math.sin
import kotlin.math.sqrt
/** 国内地图(腾讯/高德)用 GCJ-02。卫星 GPS 是 WGS-84,不转换会偏三四百米。 */
object Coord {
private const val A = 6378245.0
private const val EE = 0.00669342162296594323
fun outOfChina(lat: Double, lng: Double): Boolean {
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
}
fun wgs84ToGcj02(lat: Double, lng: Double): Pair<Double, Double> {
if (outOfChina(lat, lng)) return lat to lng
var dLat = transformLat(lng - 105.0, lat - 35.0)
var dLng = transformLng(lng - 105.0, lat - 35.0)
val radLat = lat / 180.0 * PI
var magic = 1 - EE * sin(radLat) * sin(radLat)
val sqrtMagic = sqrt(magic)
dLat = dLat * 180.0 / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
dLng = dLng * 180.0 / (A / sqrtMagic * Math.cos(radLat) * PI)
return (lat + dLat) to (lng + dLng)
}
private fun transformLat(x: Double, y: Double): Double {
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(Math.abs(x))
ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0
ret += (20.0 * sin(y * PI) + 40.0 * sin(y / 3.0 * PI)) * 2.0 / 3.0
ret += (160.0 * sin(y / 12.0 * PI) + 320 * sin(y * PI / 30.0)) * 2.0 / 3.0
return ret
}
private fun transformLng(x: Double, y: Double): Double {
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(Math.abs(x))
ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0
ret += (20.0 * sin(x * PI) + 40.0 * sin(x / 3.0 * PI)) * 2.0 / 3.0
ret += (150.0 * sin(x / 12.0 * PI) + 300.0 * sin(x / 30.0 * PI)) * 2.0 / 3.0
return ret
}
}
@@ -0,0 +1,104 @@
package com.fysxkj.fengying_oa
import android.app.Application
import android.util.Log
import com.heytap.msp.push.HeytapPushManager
import com.vivo.push.IPushActionListener
import com.vivo.push.PushClient
import com.vivo.push.PushConfig
import com.vivo.push.listener.IPushQueryActionListener
import com.vivo.push.util.VivoPushException
class FengyingApp : Application() {
override fun onCreate() {
super.onCreate()
OemAdapt.ensureChannels(this)
OppoPushCallback.appCtx = applicationContext
initVivo()
initOppo()
initMeizu()
initHuawei()
}
private fun initVivo() {
try {
val config = PushConfig.Builder().agreePrivacyStatement(true).build()
PushClient.getInstance(this).initialize(config)
PushClient.getInstance(applicationContext).turnOnPush(
object : IPushActionListener {
override fun onStateChanged(state: Int) {
if (state != 0) {
Log.w(TAG, "vivo turnOnPush $state")
return
}
PushClient.getInstance(applicationContext).getRegId(
object : IPushQueryActionListener {
override fun onSuccess(regid: String?) {
if (!regid.isNullOrBlank()) PushStore.saveReg(this@FengyingApp, regid, "vivo")
}
override fun onFail(errorCode: Int?) {
Log.w(TAG, "vivo getRegId $errorCode")
}
},
)
}
},
)
} catch (e: VivoPushException) {
Log.e(TAG, "vivo initialize", e)
} catch (e: Throwable) {
Log.e(TAG, "vivo initialize", e)
}
}
private fun initOppo() {
try {
HeytapPushManager.init(this, false)
if (!HeytapPushManager.isSupportPush(this)) return
HeytapPushManager.register(this, OPPO_APP_KEY, OPPO_APP_SECRET, OppoPushCallback())
} catch (e: Throwable) {
Log.e(TAG, "oppo initialize", e)
}
}
private fun initMeizu() {
if (PushStore.detectVendor() != "meizu") return
try {
com.meizu.cloud.pushsdk.PushManager.register(this, MEIZU_APP_ID, MEIZU_APP_KEY)
} catch (e: Throwable) {
Log.e(TAG, "meizu initialize", e)
}
}
private fun initHuawei() {
if (!PushStore.isHuaweiFamily()) return
Thread {
try {
runCatching {
if (com.huawei.agconnect.AGConnectInstance.getInstance() == null) {
com.huawei.agconnect.AGConnectInstance.initialize(this)
}
}
val token = com.huawei.hms.aaid.HmsInstanceId.getInstance(this).getToken(HUAWEI_APP_ID, "HCM")
if (!token.isNullOrBlank()) {
PushStore.saveReg(this, token, "huawei")
Log.i(TAG, "huawei token ok")
} else {
Log.w(TAG, "huawei token empty")
}
} catch (e: Throwable) {
Log.e(TAG, "huawei initialize", e)
}
}.start()
}
companion object {
private const val TAG = "FengyingPush"
private const val OPPO_APP_KEY = "e23832b52fbb46e18de6eef5affda2e9"
private const val OPPO_APP_SECRET = "e9cf0bf35052484ebcaf15d56576468b"
private const val MEIZU_APP_ID = "157643"
private const val MEIZU_APP_KEY = "4263a8d18de442b6a17203866ce1546d"
private const val HUAWEI_APP_ID = "118825899"
}
}
@@ -0,0 +1,32 @@
package com.fysxkj.fengying_oa
import android.os.Bundle
import android.util.Log
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
class HuaweiPushService : HmsMessageService() {
override fun onNewToken(token: String?) {
saveToken(token)
}
override fun onNewToken(token: String?, bundle: Bundle?) {
saveToken(token)
}
override fun onMessageReceived(message: RemoteMessage?) {
val data = message?.dataOfMap ?: return
if (data.isEmpty()) return
Log.i(TAG, "huawei data ${data.keys}")
}
private fun saveToken(token: String?) {
if (token.isNullOrBlank()) return
PushStore.saveReg(this, token, "huawei")
Log.i(TAG, "huawei token ok")
}
companion object {
private const val TAG = "FengyingPush"
}
}
@@ -0,0 +1,926 @@
package com.fysxkj.fengying_oa
import android.Manifest
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ClipData
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.location.LocationManager
import android.media.MediaPlayer
import android.media.AudioAttributes
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
class MainActivity : FlutterActivity() {
private val imChannel = "com.fysxkj.oa/im"
private val otaChannel = "com.fysxkj.oa/ota"
private val deviceEvents = "com.fysxkj.oa/device"
private val pushEvents = "com.fysxkj.oa/push"
private var initialized = false
private var screenshotSink: EventChannel.EventSink? = null
private var pushSink: EventChannel.EventSink? = null
private var pendingPush: Map<String, String>? = null
private var watching = false
private var lastShotAt = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private var mediaObserver: ContentObserver? = null
private var captureCallback: (() -> Unit)? = null
private var recorder: MediaRecorder? = null
private var recordFile: File? = null
private var player: MediaPlayer? = null
private var callTone: MediaPlayer? = null
private var pickResult: MethodChannel.Result? = null
private var locResult: MethodChannel.Result? = null
private var voiceStartResult: MethodChannel.Result? = null
private var locListener: android.location.LocationListener? = null
private var locTimeout: Runnable? = null
private var callMediaResult: MethodChannel.Result? = null
private var bestGps: android.location.Location? = null
private var bestNet: android.location.Location? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prepareIncomingCallWindow(intent)
stashPush(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
prepareIncomingCallWindow(intent)
stashPush(intent)
emitPush(intent)
}
override fun onPostResume() {
super.onPostResume()
preferMaxRefresh()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, imChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"init" -> {
initialized = true
result.success(
mapOf(
"ok" to true,
"initialized" to true,
"engine" to "dart-tcp",
),
)
}
"release" -> {
initialized = false
result.success(mapOf("ok" to true))
}
"engine" -> result.success(if (initialized) "dart-tcp" else "idle")
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, otaChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"installApk" -> {
val path = call.argument<String>("path")
if (path.isNullOrBlank()) {
result.error("bad_path", "缺少安装包路径", null)
return@setMethodCallHandler
}
try {
installApk(path)
result.success(true)
} catch (e: Exception) {
result.error("install_failed", e.message, null)
}
}
"preferMaxRefresh" -> {
preferMaxRefresh()
result.success(true)
}
"startScreenshotWatch" -> {
startScreenshotWatch()
result.success(true)
}
"stopScreenshotWatch" -> {
stopScreenshotWatch()
result.success(true)
}
"startVoice" -> startVoice(result)
"stopVoice" -> stopVoice(result, cancel = false)
"cancelVoice" -> stopVoice(result, cancel = true)
"playVoice" -> {
val path = call.argument<String>("path")
if (path.isNullOrBlank()) {
result.error("bad_path", "缺少音频路径", null)
return@setMethodCallHandler
}
playVoice(path, result)
}
"stopPlay" -> {
stopPlay()
result.success(true)
}
"startCallTone" -> {
startCallTone(call.argument<Boolean>("incoming") == true)
result.success(true)
}
"stopCallTone" -> {
stopCallTone()
result.success(true)
}
"cancelCallNotification" -> {
cancelCallNotification()
result.success(true)
}
"playMessageSent" -> {
playBundledSound(R.raw.fengying_send)
result.success(true)
}
"showOnlineNotification" -> {
val title = call.argument<String>("title") ?: "风影办公"
val content = call.argument<String>("content") ?: "您有一条新消息"
@Suppress("UNCHECKED_CAST")
val extras = (call.argument<Map<String, String>>("extras") ?: emptyMap()).mapValues { "${it.value}" }
showOnlineNotification(title, content, extras)
result.success(true)
}
"speechToText" -> {
val path = call.argument<String>("path")
if (path.isNullOrBlank()) {
result.success(null)
return@setMethodCallHandler
}
Thread {
val text =
try {
VoiceAsr.recognize(applicationContext, path)
} catch (_: Exception) {
null
}
mainHandler.post { result.success(text) }
}.start()
}
"pickMedia" -> pickContent(result, "image/*", arrayOf("image/gif", "image/png", "image/jpeg", "image/webp"))
"pickFile" -> pickContent(result, "*/*", null)
"getLocation" -> getLocation(result)
"openFile" -> {
val path = call.argument<String>("path")
val mime = call.argument<String>("mime")
if (path.isNullOrBlank()) {
result.error("bad_path", "缺少文件路径", null)
return@setMethodCallHandler
}
try {
openFile(path, mime)
result.success(true)
} catch (e: Exception) {
result.error("open_failed", e.message, null)
}
}
"saveImage" -> {
val path = call.argument<String>("path")
if (path.isNullOrBlank()) {
result.error("bad_path", "缺少图片路径", null)
return@setMethodCallHandler
}
try {
saveImage(path)
result.success(true)
} catch (e: Exception) {
result.error("save_failed", e.message, null)
}
}
"openUrl" -> {
val url = call.argument<String>("url")
if (url.isNullOrBlank()) {
result.error("bad_url", "缺少链接", null)
return@setMethodCallHandler
}
try {
openUrl(url)
result.success(true)
} catch (e: Exception) {
result.error("open_failed", e.message, null)
}
}
"getPushToken" -> result.success(PushStore.token(this))
"consumeLaunchPush" -> {
val hit = pendingPush
pendingPush = null
result.success(hit)
}
"requestPushPermission", "requestFirstLoginPermissions" -> {
requestNotifyPermission()
result.success(true)
}
"requestCallMedia" -> requestCallMedia(result)
"oemStatus" -> {
val map = OemAdapt.status().toMutableMap()
map["batteryIgnored"] = OemAdapt.batteryIgnored(this)
map["prompted"] = PushStore.oemPrompted(this)
result.success(map)
}
"openOemKeepAlive" -> {
OemAdapt.openKeepAlive(this)
result.success(true)
}
"requestBattery" -> {
OemAdapt.requestBattery(this)
result.success(true)
}
"markOemPrompted" -> {
PushStore.markOemPrompted(this)
result.success(true)
}
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, deviceEvents)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
screenshotSink = events
}
override fun onCancel(arguments: Any?) {
screenshotSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, pushEvents)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
pushSink = events
pendingPush?.let { events?.success(it) }
}
override fun onCancel(arguments: Any?) {
pushSink = null
}
},
)
preferMaxRefresh()
}
private fun requestNotifyPermission() {
if (Build.VERSION.SDK_INT < 33) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return
}
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFY)
}
private fun requestCallMedia(result: MethodChannel.Result) {
val need = mutableListOf<String>()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
need.add(Manifest.permission.RECORD_AUDIO)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
need.add(Manifest.permission.CAMERA)
}
if (need.isEmpty()) {
result.success(true)
return
}
callMediaResult = result
ActivityCompat.requestPermissions(this, need.toTypedArray(), REQ_CALL)
}
private fun stashPush(intent: Intent?) {
val extras = PushStore.extrasOf(intent)
if (extras.isNotEmpty()) pendingPush = extras
}
private fun emitPush(intent: Intent?) {
val extras = PushStore.extrasOf(intent)
if (extras.isEmpty()) return
mainHandler.post { pushSink?.success(extras) }
}
private fun preferMaxRefresh() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
try {
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
val modes = display?.supportedModes ?: return
val best = modes.maxByOrNull { it.refreshRate } ?: return
val params = window.attributes
if (params.preferredDisplayModeId != best.modeId) {
params.preferredDisplayModeId = best.modeId
window.attributes = params
}
} catch (_: Exception) {
}
}
private fun emitScreenshot() {
val now = System.currentTimeMillis()
if (now - lastShotAt < 1500) return
lastShotAt = now
mainHandler.post { screenshotSink?.success("screenshot") }
}
private fun startScreenshotWatch() {
if (watching) return
watching = true
requestMediaRead()
if (Build.VERSION.SDK_INT >= 34) {
val cb = android.app.Activity.ScreenCaptureCallback { emitScreenshot() }
captureCallback = { unregisterScreenCaptureCallback(cb) }
registerScreenCaptureCallback(mainExecutor, cb)
}
val observer =
object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
mainHandler.post { inspectScreenshot(uri) }
}
}
mediaObserver = observer
try {
contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer)
} catch (_: Exception) {
}
}
private fun requestMediaRead() {
val perm =
if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
else Manifest.permission.READ_EXTERNAL_STORAGE
if (ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED) return
ActivityCompat.requestPermissions(this, arrayOf(perm), REQ_MEDIA)
}
private fun inspectScreenshot(uri: Uri?) {
val uriText = uri?.toString()?.lowercase() ?: ""
if (uriText.contains("screenshot") || uriText.contains("screen_shot") || uriText.contains("截屏")) {
emitScreenshot()
return
}
try {
val projection =
arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.RELATIVE_PATH,
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.DATE_ADDED,
)
val target = uri ?: MediaStore.Images.Media.EXTERNAL_CONTENT_URI
contentResolver.query(target, projection, null, null, "${MediaStore.Images.Media.DATE_ADDED} DESC")?.use { c ->
if (!c.moveToFirst()) return
val added = if (c.columnCount > 3) c.getLong(3) else 0L
if (added > 0 && System.currentTimeMillis() / 1000 - added > 8) return
val blob = "${c.getString(0) ?: ""} ${c.getString(1) ?: ""} ${if (c.columnCount > 2) c.getString(2) ?: "" else ""}".lowercase()
if (
blob.contains("screenshot") ||
blob.contains("screen_shot") ||
blob.contains("截屏") ||
blob.contains("截圖") ||
blob.contains("screen capture")
) {
emitScreenshot()
}
}
} catch (_: Exception) {
}
}
private fun stopScreenshotWatch() {
watching = false
if (Build.VERSION.SDK_INT >= 34) {
try {
captureCallback?.invoke()
} catch (_: Exception) {
}
captureCallback = null
}
mediaObserver?.let {
try {
contentResolver.unregisterContentObserver(it)
} catch (_: Exception) {
}
}
mediaObserver = null
}
/** Keep a call visible over the lock screen when the user enters through an OEM push. */
private fun prepareIncomingCallWindow(intent: Intent?) {
val extras = PushStore.extrasOf(intent)
if (extras["kind"] != "call" || extras["action"] in setOf("end", "reject", "timeout")) return
try {
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} catch (_: Exception) {
}
}
private fun startCallTone(incoming: Boolean) {
if (callTone?.isPlaying == true) return
stopCallTone()
try {
val res = if (incoming) R.raw.fengying_incoming else R.raw.fengying_dial
callTone = MediaPlayer.create(this, res)?.apply {
isLooping = true
setAudioAttributes(
AudioAttributes.Builder().setUsage(
if (incoming) AudioAttributes.USAGE_NOTIFICATION_RINGTONE else AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING,
).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build(),
)
start()
}
} catch (_: Exception) {
stopCallTone()
}
}
private fun stopCallTone() {
try { callTone?.stop() } catch (_: Exception) {}
try { callTone?.release() } catch (_: Exception) {}
callTone = null
}
private fun playBundledSound(res: Int) {
try {
MediaPlayer.create(this, res)?.apply {
setOnCompletionListener { it.release() }
start()
}
} catch (_: Exception) {
}
}
private fun notificationIntent(extras: Map<String, String>, requestCode: Int): PendingIntent {
val i = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
extras.forEach { (k, v) -> putExtra(k, v) }
data = Uri.parse("fengyingoa://chat/open?$requestCode")
}
return PendingIntent.getActivity(
this,
requestCode,
i,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun showOnlineNotification(title: String, content: String, extras: Map<String, String>) {
val isCall = extras["kind"] == "call" && extras["action"] !in setOf("end", "reject", "timeout")
val id = if (isCall) CALL_NOTICE_ID else (System.currentTimeMillis() % 100000).toInt()
val intent = notificationIntent(extras, id)
val b = NotificationCompat.Builder(this, if (isCall) PushStore.CHANNEL_CALL else PushStore.CHANNEL_IM)
.setSmallIcon(android.R.drawable.sym_action_chat)
.setContentTitle(title)
.setContentText(content)
.setAutoCancel(!isCall)
.setContentIntent(intent)
.setPriority(if (isCall) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
.setCategory(if (isCall) NotificationCompat.CATEGORY_CALL else NotificationCompat.CATEGORY_MESSAGE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
if (isCall) {
b.setOngoing(true).setFullScreenIntent(intent, true)
}
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(id, b.build())
}
private fun cancelCallNotification() {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.cancel(CALL_NOTICE_ID)
}
private fun hasMic(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
}
private fun startVoice(result: MethodChannel.Result) {
if (!hasMic()) {
voiceStartResult = result
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC)
return
}
beginRecord(result)
}
private fun beginRecord(result: MethodChannel.Result) {
try {
stopPlay()
recorder?.release()
val file = File(cacheDir, "voice_${System.currentTimeMillis()}.m4a")
recordFile = file
val rec =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(64000)
rec.setAudioChannels(1)
rec.setOutputFile(file.absolutePath)
rec.prepare()
rec.start()
recorder = rec
result.success(true)
} catch (e: Exception) {
recorder?.release()
recorder = null
recordFile?.delete()
recordFile = null
result.error("record_failed", e.message ?: "无法开始录音", null)
}
}
private fun stopVoice(result: MethodChannel.Result, cancel: Boolean) {
val file = recordFile
try {
recorder?.stop()
} catch (_: Exception) {
}
try {
recorder?.release()
} catch (_: Exception) {
}
recorder = null
recordFile = null
if (cancel || file == null || !file.exists() || file.length() < 200) {
file?.delete()
result.success(null)
return
}
result.success(mapOf("path" to file.absolutePath, "name" to file.name))
}
private fun playVoice(path: String, result: MethodChannel.Result) {
try {
stopPlay()
val p = MediaPlayer()
p.setDataSource(path)
p.setOnCompletionListener {
stopPlay()
}
p.prepare()
p.start()
player = p
result.success(true)
} catch (e: Exception) {
stopPlay()
result.error("play_failed", e.message ?: "无法播放", null)
}
}
private fun stopPlay() {
try {
player?.stop()
} catch (_: Exception) {
}
try {
player?.release()
} catch (_: Exception) {
}
player = null
}
private fun pickContent(result: MethodChannel.Result, type: String, extras: Array<String>?) {
pickResult = result
val intent =
Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
this.type = type
if (extras != null) putExtra(Intent.EXTRA_MIME_TYPES, extras)
}
startActivityForResult(Intent.createChooser(intent, "选择文件"), REQ_PICK)
}
private fun hasLoc(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
private fun getLocation(result: MethodChannel.Result) {
if (!hasLoc()) {
locResult = result
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
REQ_LOC,
)
return
}
emitLocation(result)
}
@Suppress("MissingPermission")
private fun emitLocation(result: MethodChannel.Result) {
try {
val lm = getSystemService(LOCATION_SERVICE) as LocationManager
stopLocUpdates(lm)
locResult = result
bestGps = null
bestNet = null
val listener =
android.location.LocationListener { loc ->
if (loc.provider == LocationManager.GPS_PROVIDER) {
bestGps = loc
if (loc.accuracy <= 50f) finishLoc(lm, loc)
} else {
val cur = bestNet
if (cur == null || loc.accuracy < cur.accuracy) bestNet = loc
}
}
locListener = listener
if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, listener, Looper.getMainLooper())
}
if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0f, listener, Looper.getMainLooper())
}
val timeout =
Runnable {
val gps = bestGps
val net = bestNet
val last = bestLastLocation(lm)
val chosen =
when {
gps != null -> gps
net != null -> net
last != null -> last
else -> null
}
if (chosen != null) finishLoc(lm, chosen)
else {
stopLocUpdates(lm)
val cb = locResult
locResult = null
cb?.error("no_location", "没有拿到定位,请到开阔处打开系统定位后再试", null)
}
}
locTimeout = timeout
mainHandler.postDelayed(timeout, 16000)
} catch (e: Exception) {
result.error("no_location", e.message ?: "定位失败", null)
}
}
@Suppress("MissingPermission")
private fun bestLastLocation(lm: LocationManager): android.location.Location? {
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER)
var best: android.location.Location? = null
for (p in providers) {
val loc =
try {
lm.getLastKnownLocation(p)
} catch (_: Exception) {
null
} ?: continue
if (best == null) {
best = loc
continue
}
val newer = loc.time - best.time > 15_000
val moreAccurate = loc.accuracy + 8f < best.accuracy
if (newer || moreAccurate) best = loc
}
return best
}
@Suppress("MissingPermission")
private fun finishLoc(lm: LocationManager, loc: android.location.Location) {
stopLocUpdates(lm)
val cb = locResult
locResult = null
cb?.success(locMap(loc))
}
@Suppress("MissingPermission")
private fun stopLocUpdates(lm: LocationManager) {
locTimeout?.let { mainHandler.removeCallbacks(it) }
locTimeout = null
locListener?.let {
try {
lm.removeUpdates(it)
} catch (_: Exception) {
}
}
locListener = null
}
private fun locMap(loc: android.location.Location): Map<String, Double> {
var lat = loc.latitude
var lng = loc.longitude
val p = loc.provider ?: ""
if (p != LocationManager.NETWORK_PROVIDER) {
val gcj = Coord.wgs84ToGcj02(lat, lng)
lat = gcj.first
lng = gcj.second
}
return mapOf(
"lat" to lat,
"lng" to lng,
"accuracy" to loc.accuracy.toDouble(),
)
}
private fun openFile(path: String, mime: String?) {
val file = File(path)
if (!file.exists()) throw IllegalStateException("文件不存在")
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
val mimeType = if (mime.isNullOrBlank()) "*/*" else mime
val view =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val matches = packageManager.queryIntentActivities(view, PackageManager.MATCH_DEFAULT_ONLY)
for (ri in matches) {
grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (matches.isNotEmpty()) {
startActivity(Intent.createChooser(view, "打开文件").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return
}
val send =
Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
setType(mimeType)
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val sendMatches = packageManager.queryIntentActivities(send, PackageManager.MATCH_DEFAULT_ONLY)
for (ri in sendMatches) {
grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (sendMatches.isEmpty()) {
throw IllegalStateException("手机上没有能打开此文件的应用,请先安装 WPS 或 Office")
}
startActivity(Intent.createChooser(send, "打开文件").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
private fun saveImage(path: String) {
val file = File(path)
if (!file.exists()) throw IllegalStateException("图片不存在")
val values =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "fy_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IllegalStateException("无法写入相册")
contentResolver.openOutputStream(uri)?.use { out ->
file.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("无法写入相册")
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != REQ_PICK) return
val cb = pickResult
pickResult = null
if (cb == null) return
if (resultCode != RESULT_OK || data?.data == null) {
cb.success(null)
return
}
try {
cb.success(copyUri(data.data!!))
} catch (e: Exception) {
cb.error("pick_failed", e.message, null)
}
}
private fun copyUri(uri: Uri): Map<String, String> {
var name = "sticker.gif"
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
if (c.moveToFirst()) {
val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (i >= 0) name = c.getString(i) ?: name
}
}
val mime = contentResolver.getType(uri) ?: ""
val safe = name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val out = File(cacheDir, "pick_${System.currentTimeMillis()}_$safe")
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(out).use { input.copyTo(it) }
} ?: throw IllegalStateException("无法读取所选文件")
return mapOf("path" to out.absolutePath, "name" to name, "mime" to mime)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_MIC) {
val cb = voiceStartResult
voiceStartResult = null
if (cb == null) return
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
beginRecord(cb)
} else {
cb.error("need_permission", "请允许麦克风权限后再按住说话", null)
}
return
}
if (requestCode == REQ_CALL) {
val cb = callMediaResult
callMediaResult = null
if (cb == null) return
cb.success(grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED })
return
}
if (requestCode == REQ_LOC) {
val cb = locResult
locResult = null
if (cb == null) return
if (grantResults.any { it == PackageManager.PERMISSION_GRANTED }) {
emitLocation(cb)
} else {
cb.error("need_permission", "请允许位置权限后再发送位置", null)
}
}
}
override fun onDestroy() {
stopScreenshotWatch()
try {
recorder?.release()
} catch (_: Exception) {
}
stopPlay()
stopCallTone()
super.onDestroy()
}
private fun installApk(path: String) {
val file = File(path)
if (!file.exists()) throw IllegalStateException("安装包不存在")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()
) {
startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
companion object {
private const val REQ_MIC = 91
private const val REQ_PICK = 88
private const val REQ_LOC = 93
private const val REQ_NOTIFY = 94
private const val REQ_CALL = 95
private const val REQ_MEDIA = 96
private const val CALL_NOTICE_ID = 73101
}
}
@@ -0,0 +1,71 @@
package com.fysxkj.fengying_oa
import android.content.Context
import android.content.Intent
import android.util.Log
import com.meizu.cloud.pushsdk.MzPushMessageReceiver
import com.meizu.cloud.pushsdk.handler.MzPushMessage
import com.meizu.cloud.pushsdk.platform.message.PushSwitchStatus
import com.meizu.cloud.pushsdk.platform.message.RegisterStatus
import com.meizu.cloud.pushsdk.platform.message.SubAliasStatus
import com.meizu.cloud.pushsdk.platform.message.SubTagsStatus
import com.meizu.cloud.pushsdk.platform.message.UnRegisterStatus
import org.json.JSONObject
class MeizuPushReceiver : MzPushMessageReceiver() {
override fun onRegisterStatus(context: Context?, registerStatus: RegisterStatus?) {
val pushId = registerStatus?.pushId
if (context == null || pushId.isNullOrBlank()) {
Log.w(TAG, "meizu register ${registerStatus?.code} ${registerStatus?.message}")
return
}
PushStore.saveReg(context, pushId, "meizu")
Log.i(TAG, "meizu register ok")
}
override fun onUnRegisterStatus(context: Context?, unRegisterStatus: UnRegisterStatus?) {}
override fun onPushStatus(context: Context?, pushSwitchStatus: PushSwitchStatus?) {}
override fun onSubTagsStatus(context: Context?, subTagsStatus: SubTagsStatus?) {}
override fun onSubAliasStatus(context: Context?, subAliasStatus: SubAliasStatus?) {}
override fun onMessage(context: Context?, message: String?, platformExtra: String?) {}
override fun onNotificationClicked(context: Context?, mzPushMessage: MzPushMessage?) {
val ctx = context ?: return
val intent =
Intent(ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtras(parseExtras(mzPushMessage?.selfDefineContentString))
}
runCatching { ctx.startActivity(intent) }
.onFailure { Log.w(TAG, "meizu click", it) }
}
override fun onNotificationArrived(context: Context?, mzPushMessage: MzPushMessage?) {}
override fun onNotificationDeleted(context: Context?, mzPushMessage: MzPushMessage?) {}
companion object {
private const val TAG = "FengyingPush"
private fun parseExtras(raw: String?): android.os.Bundle {
val extras = android.os.Bundle()
if (raw.isNullOrBlank()) return extras
runCatching {
val obj = JSONObject(raw)
val src = if (obj.has("parameters")) obj.getJSONObject("parameters") else obj
for (key in listOf(
"conversationId", "kind", "fromName", "peerId", "title", "bizType", "bizId",
"callId", "callKind", "action", "fromUserId",
)) {
val v = src.optString(key)
if (v.isNotBlank()) extras.putString(key, v)
}
}
return extras
}
}
}
@@ -0,0 +1,183 @@
package com.fysxkj.fengying_oa
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
object OemAdapt {
private const val TAG = "FengyingOem"
fun ensureChannels(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val im =
NotificationChannel(PushStore.CHANNEL_IM, "聊天消息", NotificationManager.IMPORTANCE_HIGH).apply {
description = "同事发来的即时消息和通话"
enableVibration(true)
enableLights(true)
setShowBadge(true)
val sound = Uri.parse("android.resource://${ctx.packageName}/raw/fengying_notification")
setSound(sound, AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build())
}
val call =
NotificationChannel(PushStore.CHANNEL_CALL, "音视频来电", NotificationManager.IMPORTANCE_HIGH).apply {
description = "语音和视频来电提醒"
enableVibration(true)
enableLights(true)
setShowBadge(true)
val sound = Uri.parse("android.resource://${ctx.packageName}/raw/fengying_incoming")
setSound(sound, AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build())
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
val todo =
NotificationChannel(PushStore.CHANNEL_TODO, "待办与审批", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "审批、待办和办公提醒"
setShowBadge(true)
}
nm.createNotificationChannel(im)
nm.createNotificationChannel(call)
nm.createNotificationChannel(todo)
}
fun batteryIgnored(ctx: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(ctx.packageName)
}
fun requestBattery(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
if (batteryIgnored(ctx)) return
try {
val intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${ctx.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
ctx.startActivity(intent)
} catch (e: Exception) {
Log.w(TAG, "battery intent", e)
openAppDetails(ctx)
}
}
fun status(): Map<String, Any> {
val vendor = PushStore.detectVendor()
val (title, hint) =
when (vendor) {
"oppo" ->
"后台运行与通知" to "请允许自启动、允许后台运行,并把电池优化设为不优化,否则 ColorOS 会杀掉办公消息。"
"vivo" ->
"后台运行与通知" to "请在 i 管家 / 权限管理里允许自启动、允许后台高耗电,并关闭省电限制,OriginOS 才会及时弹出消息。"
"meizu" ->
"后台运行与通知" to "请在手机管家里允许自启动、允许后台运行,并关闭电池优化,Flyme 才会及时弹出消息。"
"huawei" ->
"后台运行与通知" to "请允许自启动、允许关联启动,并把电池优化设为不允许,否则鸿蒙/EMUI 会杀掉办公消息。"
else ->
"后台运行与通知" to "请允许通知,并把本应用加入电池优化白名单,以便及时收到消息。"
}
return mapOf(
"vendor" to vendor,
"title" to title,
"hint" to hint,
)
}
fun openKeepAlive(ctx: Context) {
val vendor = PushStore.detectVendor()
val intents =
when (vendor) {
"oppo" -> oppoIntents(ctx)
"vivo" -> vivoIntents(ctx)
"meizu" -> meizuIntents(ctx)
"huawei" -> huaweiIntents()
else -> emptyList()
}
for (intent in intents) {
if (launch(ctx, intent)) return
}
openAppDetails(ctx)
}
private fun oppoIntents(ctx: Context): List<Intent> {
val pkg = ctx.packageName
return listOf(
component("com.oplus.safecenter", "com.oplus.safecenter.startupapp.view.StartupAppListActivity"),
component("com.coloros.safecenter", "com.coloros.safecenter.startupapp.StartupAppListActivity"),
component("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity"),
component("com.oppo.safe", "com.oppo.safe.permission.startup.StartupAppListActivity"),
component("com.coloros.oppoguardelf", "com.coloros.powermanager.fuelgaue.PowerConsumptionActivity"),
Intent().setClassName("com.coloros.safecenter", "com.coloros.privacypermissionsentry.PermissionTopActivity"),
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
).onEach { it.putExtra("packageName", pkg) }
}
private fun vivoIntents(ctx: Context): List<Intent> {
val pkg = ctx.packageName
return listOf(
component("com.vivo.permissionmanager", "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"),
component("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.BgStartUpManager"),
component("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"),
component("com.vivo.abe", "com.vivo.applicationbehaviorengine.ui.ExcessivePowerManagerActivity"),
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
).onEach { it.putExtra("packageName", pkg) }
}
private fun huaweiIntents(): List<Intent> {
return listOf(
component("com.huawei.systemmanager", "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"),
component("com.huawei.systemmanager", "com.huawei.systemmanager.appcontrol.activity.StartupAppControlActivity"),
component("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity"),
component("com.huawei.systemmanager", "com.huawei.systemmanager.power.ui.HwPowerManagerActivity"),
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
)
}
private fun meizuIntents(ctx: Context): List<Intent> {
val pkg = ctx.packageName
return listOf(
Intent("com.meizu.safe.security.SHOW_APPSEC").apply {
setClassName("com.meizu.safe", "com.meizu.safe.security.AppSecActivity")
putExtra("packageName", pkg)
},
component("com.meizu.safe", "com.meizu.safe.permission.PermissionMainActivity"),
component("com.meizu.safe", "com.meizu.safe.powerui.PowerAppPermissionActivity"),
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
)
}
private fun component(pkg: String, cls: String) =
Intent().setComponent(ComponentName(pkg, cls)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
private fun openAppDetails(ctx: Context) {
try {
ctx.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${ctx.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
} catch (e: Exception) {
Log.w(TAG, "details", e)
}
}
private fun launch(ctx: Context, intent: Intent): Boolean {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return try {
if (intent.resolveActivity(ctx.packageManager) == null) return false
ctx.startActivity(intent)
true
} catch (_: Exception) {
false
}
}
}
@@ -0,0 +1,11 @@
package com.fysxkj.fengying_oa
import android.content.Context
import com.heytap.msp.push.mode.DataMessage
import com.heytap.msp.push.service.CompatibleDataMessageCallbackService
class OppoCompatPushService : CompatibleDataMessageCallbackService() {
override fun processMessage(context: Context?, message: DataMessage?) {
super.processMessage(context, message)
}
}
@@ -0,0 +1,11 @@
package com.fysxkj.fengying_oa
import android.content.Context
import com.heytap.msp.push.mode.DataMessage
import com.heytap.msp.push.service.DataMessageCallbackService
class OppoDataMessageService : DataMessageCallbackService() {
override fun processMessage(context: Context?, message: DataMessage?) {
super.processMessage(context, message)
}
}
@@ -0,0 +1,34 @@
package com.fysxkj.fengying_oa
import android.content.Context
import android.util.Log
import com.heytap.msp.push.callback.ICallBackResultService
class OppoPushCallback : ICallBackResultService {
override fun onRegister(responseCode: Int, registerID: String?) {
if (responseCode == 0 && !registerID.isNullOrBlank()) {
val ctx = appCtx ?: return
PushStore.saveReg(ctx, registerID, "oppo")
Log.i(TAG, "oppo register ok")
} else {
Log.w(TAG, "oppo register $responseCode")
}
}
override fun onUnRegister(responseCode: Int) {}
override fun onSetPushTime(responseCode: Int, pushTime: String?) {}
override fun onGetPushStatus(responseCode: Int, status: Int) {}
override fun onGetNotificationStatus(responseCode: Int, status: Int) {}
override fun onError(errorCode: Int, message: String?) {
Log.w(TAG, "oppo error $errorCode $message")
}
companion object {
private const val TAG = "FengyingPush"
@Volatile var appCtx: Context? = null
}
}
@@ -0,0 +1,89 @@
package com.fysxkj.fengying_oa
import android.content.Context
import android.content.Intent
import android.os.Build
object PushStore {
const val PREFS = "fy_push"
const val KEY_REG = "regId"
const val KEY_VENDOR = "vendor"
// New id makes the bundled custom sound take effect even on phones that
// already created the legacy fy_im notification channel.
const val CHANNEL_IM = "fy_im_custom"
const val CHANNEL_CALL = "fy_call_v2"
const val CHANNEL_TODO = "fy_todo"
const val KEY_OEM_PROMPT = "oem_prompted"
fun saveReg(ctx: Context, id: String, vendor: String = detectVendor()) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_REG, id)
.putString(KEY_VENDOR, vendor)
.apply()
}
fun token(ctx: Context): Map<String, String> {
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val reg = p.getString(KEY_REG, "") ?: ""
val vendor = p.getString(KEY_VENDOR, detectVendor()) ?: detectVendor()
return mapOf(
"vendor" to vendor,
"regId" to reg,
"brand" to Build.BRAND,
"model" to Build.MODEL,
)
}
fun detectVendor(): String {
val m = "${Build.MANUFACTURER} ${Build.BRAND} ${Build.FINGERPRINT}".lowercase()
return when {
m.contains("vivo") || m.contains("iqoo") -> "vivo"
m.contains("oppo") || m.contains("realme") || m.contains("oneplus") || m.contains("oplus") -> "oppo"
m.contains("meizu") -> "meizu"
isHuaweiFamily(m) -> "huawei"
m.contains("xiaomi") || m.contains("redmi") || m.contains("blackshark") -> "xiaomi"
else -> "android"
}
}
fun isHuaweiFamily(raw: String = "${Build.MANUFACTURER} ${Build.BRAND} ${Build.FINGERPRINT}".lowercase()): Boolean {
return listOf("huawei", "honor", "harmony", "hmos", "ohos", "harmonyos").any { raw.contains(it) }
}
fun extrasOf(intent: Intent?): Map<String, String> {
val out = linkedMapOf<String, String>()
val data = intent?.data
if (data != null && data.scheme == "fengyingoa") {
when (data.host) {
"qr" -> {
val ticket = data.getQueryParameter("ticket")
if (!ticket.isNullOrBlank()) {
out["kind"] = "qr-login"
out["ticket"] = ticket
}
}
"chat" -> {
val cid = data.getQueryParameter("conversationId")
if (!cid.isNullOrBlank()) out["conversationId"] = cid
}
}
}
val b = intent?.extras ?: return out
for (key in listOf(
"conversationId", "kind", "fromName", "peerId", "title", "bizType", "bizId", "ticket",
"callId", "callKind", "action", "fromUserId",
)) {
val v = runCatching { b.getString(key) }.getOrNull()
if (!v.isNullOrBlank() && !out.containsKey(key)) out[key] = v
}
return out
}
fun oemPrompted(ctx: Context) =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_OEM_PROMPT, false)
fun markOemPrompted(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_OEM_PROMPT, true).apply()
}
}
@@ -0,0 +1,11 @@
package com.fysxkj.fengying_oa
import android.content.Context
import com.vivo.push.sdk.OpenClientPushMessageReceiver
class VivoPushReceiver : OpenClientPushMessageReceiver() {
override fun onReceiveRegId(context: Context?, regId: String?) {
if (context == null || regId.isNullOrBlank()) return
PushStore.saveReg(context, regId, "vivo")
}
}
@@ -0,0 +1,222 @@
package com.fysxkj.fengying_oa
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import org.json.JSONObject
import org.vosk.LibVosk
import org.vosk.LogLevel
import org.vosk.Model
import org.vosk.Recognizer
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.ZipInputStream
/** 手机端离线语音转文字(Vosk,首次使用自动下载中文小模型到应用目录)。 */
object VoiceAsr {
private const val MODEL_DIR = "vosk-model-small-cn-0.22"
private const val MODEL_URL =
"https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"
@Volatile
private var model: Model? = null
@Synchronized
fun recognize(context: Context, audioPath: String): String? {
LibVosk.setLogLevel(LogLevel.WARN)
val m = model ?: loadModel(context.applicationContext).also { model = it }
val pcm = decodeToPcm16Mono16k(audioPath) ?: return null
if (pcm.isEmpty()) return null
val recognizer = Recognizer(m, 16000f)
try {
recognizer.acceptWaveForm(pcm, pcm.size)
val json = JSONObject(recognizer.finalResult)
val text = json.optString("text", "").trim()
return text.ifEmpty { null }
} finally {
recognizer.close()
}
}
private fun loadModel(context: Context): Model {
val dir = File(context.filesDir, MODEL_DIR)
if (!File(dir, "am/final.mdl").exists()) {
downloadAndUnzip(dir)
}
return Model(dir.absolutePath)
}
private fun downloadAndUnzip(targetDir: File) {
if (targetDir.exists()) {
targetDir.deleteRecursively()
}
targetDir.mkdirs()
val zipFile = File(targetDir.parentFile, "$MODEL_DIR.zip")
val conn = (URL(MODEL_URL).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 120_000
requestMethod = "GET"
}
conn.inputStream.use { input ->
FileOutputStream(zipFile).use { output -> input.copyTo(output) }
}
conn.disconnect()
unzip(zipFile, targetDir.parentFile!!)
zipFile.delete()
val nested = File(targetDir.parentFile, MODEL_DIR)
if (!nested.exists()) {
targetDir.parentFile?.listFiles()?.firstOrNull {
it.isDirectory && File(it, "am/final.mdl").exists()
}?.renameTo(nested)
}
}
private fun unzip(zipFile: File, destDir: File) {
ZipInputStream(BufferedInputStream(zipFile.inputStream())).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
val outFile = File(destDir, entry.name)
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { fos -> zis.copyTo(fos) }
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}
private fun decodeToPcm16Mono16k(path: String): ByteArray? {
val extractor = MediaExtractor()
try {
extractor.setDataSource(path)
var track = -1
var format: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val f = extractor.getTrackFormat(i)
val mime = f.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) {
track = i
format = f
break
}
}
if (track < 0 || format == null) return null
extractor.selectTrack(track)
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
val codec = MediaCodec.createDecoderByType(mime)
codec.configure(format, null, null, 0)
codec.start()
val pcmOut = ByteArrayOutputStream()
var sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
while (true) {
if (!inputDone) {
val inIndex = codec.dequeueInputBuffer(10_000)
if (inIndex >= 0) {
val buf = codec.getInputBuffer(inIndex) ?: continue
val size = extractor.readSampleData(buf, 0)
if (size < 0) {
codec.queueInputBuffer(
inIndex,
0,
0,
0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
inputDone = true
} else {
codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
val outIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000)
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
val outFormat = codec.outputFormat
sampleRate = outFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
channels = outFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
} else if (outIndex >= 0) {
val buf = codec.getOutputBuffer(outIndex) ?: continue
if (bufferInfo.size > 0) {
val chunk = ByteArray(bufferInfo.size)
buf.position(bufferInfo.offset)
buf.limit(bufferInfo.offset + bufferInfo.size)
buf.get(chunk)
pcmOut.write(chunk)
}
codec.releaseOutputBuffer(outIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
}
}
codec.stop()
codec.release()
var samples = pcmOut.toByteArray()
if (samples.isEmpty()) return null
var shorts = bytesToShorts(samples)
if (channels > 1) {
shorts = downmix(shorts, channels)
}
if (sampleRate != 16000) {
shorts = resample(shorts, sampleRate, 16000)
}
return shortsToBytes(shorts)
} catch (_: Exception) {
return null
} finally {
try {
extractor.release()
} catch (_: Exception) {
}
}
}
private fun bytesToShorts(bytes: ByteArray): ShortArray {
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val count = bytes.size / 2
val out = ShortArray(count)
for (i in 0 until count) out[i] = bb.short
return out
}
private fun shortsToBytes(samples: ShortArray): ByteArray {
val bb = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN)
for (s in samples) bb.putShort(s)
return bb.array()
}
private fun downmix(samples: ShortArray, channels: Int): ShortArray {
if (channels <= 1) return samples
val frames = samples.size / channels
val out = ShortArray(frames)
for (i in 0 until frames) {
var sum = 0
for (c in 0 until channels) sum += samples[i * channels + c]
out[i] = (sum / channels).toShort()
}
return out
}
private fun resample(samples: ShortArray, fromRate: Int, toRate: Int): ShortArray {
if (fromRate == toRate) return samples
val ratio = fromRate.toDouble() / toRate.toDouble()
val outLen = (samples.size / ratio).toInt().coerceAtLeast(1)
val out = ShortArray(outLen)
for (i in 0 until outLen) {
val src = (i * ratio).toInt().coerceIn(0, samples.size - 1)
out[i] = samples[src]
}
return out
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
<external-cache-path name="ext_cache" path="." />
<files-path name="files" path="." />
</paths>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>