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:
@@ -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,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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
@@ -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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
Reference in New Issue
Block a user